@codyswann/lisa 4.26.1 → 4.26.3
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-hooks/parity-safety-net.sh +64 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +6 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts +5 -0
- package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +8 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +8 -7
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/parity-safety-net.sh +64 -1
- package/expo/create-only/.github/workflows/nightly-e2e-report.yml +21 -6
- package/package.json +3 -3
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-jira-journey/SKILL.md +24 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
- package/plugins/lisa/hooks/parity-safety-net.sh +64 -1
- package/plugins/lisa/skills/lisa-jira-journey/SKILL.md +24 -2
- package/plugins/lisa/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +64 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-jira-journey/SKILL.md +24 -2
- package/plugins/lisa-agy/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
- 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 +1 -1
- package/plugins/lisa-copilot/hooks/parity-safety-net.sh +64 -1
- package/plugins/lisa-copilot/skills/lisa-jira-journey/SKILL.md +24 -2
- package/plugins/lisa-copilot/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +64 -1
- package/plugins/lisa-cursor/skills/lisa-jira-journey/SKILL.md +24 -2
- package/plugins/lisa-cursor/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
- 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/hooks/parity-safety-net.sh +64 -1
- package/plugins/src/base/skills/lisa-jira-journey/SKILL.md +24 -2
- package/plugins/src/base/skills/lisa-jira-journey/scripts/parse-plan.py +144 -30
- package/scripts/generate-nightly-e2e-guard-certificate.mjs +17 -1
- package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +238 -44
|
@@ -1035,6 +1035,69 @@ fi
|
|
|
1035
1035
|
# purpose, and blocking those would be the collision that gets a guard switched
|
|
1036
1036
|
# off. The trailing `([/[:space:]'"$qc"']|$)` is what draws that line.
|
|
1037
1037
|
readonly GIT_CONTROL_PLANE='(^|[[:space:]='"$qc"'./])\.git([/[:space:]'"$qc"']|$)'
|
|
1038
|
+
# This guard is the one segment walk that pairs TWO facts found in different
|
|
1039
|
+
# places: the protected target (`.git`) and the destructive verb (a recursive
|
|
1040
|
+
# forced delete). That makes its segmentation load-bearing in a way the rm and
|
|
1041
|
+
# push walks' is not — those two read a verb and its own operands, which are
|
|
1042
|
+
# always in one pipeline stage, so splitting on `|` costs them nothing.
|
|
1043
|
+
#
|
|
1044
|
+
# Here it cost everything. The old feeder was `tr '&|;' '\n'`, which cut a
|
|
1045
|
+
# PIPELINE into pieces before the pairing question was asked, and a pipeline is
|
|
1046
|
+
# exactly how a delete reaches a target named upstream of it:
|
|
1047
|
+
# `find .git -type f -print0 | xargs -0 rm -rf` became `find .git -type f
|
|
1048
|
+
# -print0` (a target, no verb) and `xargs -0 rm -rf` (a verb, no target), and
|
|
1049
|
+
# neither half could answer. Measured before this fix, with the blanket
|
|
1050
|
+
# `xargs rm -rf` rule masking the reported shape: `find .git -type d | parallel
|
|
1051
|
+
# rm -rf`, `… | /usr/bin/xargs rm -rf`, `… | xargs -0 -- rm --recursive
|
|
1052
|
+
# --force`, `… 2>&1 | parallel rm -rf`, `… |& parallel rm -rf` and
|
|
1053
|
+
# `cat .git/config | grep url | rm -rf refs` were ALL allowed (CodySwannGT/lisa#3320).
|
|
1054
|
+
#
|
|
1055
|
+
# So segment on STATEMENT boundaries and nothing else. A pipe joins stages of
|
|
1056
|
+
# one statement; `;`, `&`, `&&` and `||` end one. Three spellings have to be
|
|
1057
|
+
# normalized first or a naive split gets them wrong in both directions:
|
|
1058
|
+
#
|
|
1059
|
+
# `|&` bash's pipe-with-stderr. Splitting its `&` would halve a pipeline —
|
|
1060
|
+
# the same fail-open, wearing a different operator. Becomes `|`.
|
|
1061
|
+
# `2>&1` a redirection, not a boundary (also `>&2`, `<&3`, `&>file`). These
|
|
1062
|
+
# are where `&` appears WITHOUT ending a statement, and they are
|
|
1063
|
+
# common enough that ignoring them would reopen the hole on any
|
|
1064
|
+
# pipeline that redirects stderr. The `&` is folded away.
|
|
1065
|
+
# `||` a real boundary that happens to be built from pipe characters, so it
|
|
1066
|
+
# must be recognized BEFORE a single `|` is spared. Becomes `;`.
|
|
1067
|
+
#
|
|
1068
|
+
# Every one of those rewrites only ever REMOVES a boundary or leaves it intact,
|
|
1069
|
+
# so each can merge two segments but never split one — the fail-closed
|
|
1070
|
+
# direction. `&` and `;` still separate, so unrelated commands are not joined.
|
|
1071
|
+
#
|
|
1072
|
+
# The leading awk is the same idea one level up. A line ending in `|` is bash's
|
|
1073
|
+
# OTHER implicit continuation — the pipeline resumes on the next line — and the
|
|
1074
|
+
# per-line read below would otherwise part the stages exactly as `tr` did, so
|
|
1075
|
+
# `find .git -type d |`↵` parallel rm -rf` fails open through the newline
|
|
1076
|
+
# instead of through the pipe. This mirrors the backslash-continuation join that
|
|
1077
|
+
# `normalized_command_str` already performs for the same reason. It is done here
|
|
1078
|
+
# rather than in that shared normalization deliberately: this guard is the one
|
|
1079
|
+
# that pairs facts across stages, so it is the one that needs the stages kept
|
|
1080
|
+
# together, and widening the shared normalization would change every other
|
|
1081
|
+
# guard's segmentation for no defect anyone has measured.
|
|
1082
|
+
#
|
|
1083
|
+
# The status check is the second half of the fix and closes a quieter hole. This
|
|
1084
|
+
# feeder had none, while the rm and push walks above both have one: a segmenter
|
|
1085
|
+
# that could not run produced no lines, the loop body never executed, and the
|
|
1086
|
+
# guard was skipped with the command ALLOWED. A segmentation that cannot answer
|
|
1087
|
+
# must refuse, exactly like a scan that cannot answer.
|
|
1088
|
+
git_segments_status=0
|
|
1089
|
+
git_segments="$(printf '%s' "$normalized_command_str" \
|
|
1090
|
+
| awk '{ if (/\|[ \t]*$/) printf "%s ", $0; else print }' \
|
|
1091
|
+
| sed -e 's/|&/|/g' \
|
|
1092
|
+
-e 's/&>/>/g' \
|
|
1093
|
+
-e 's/[0-9]*>&[0-9-]*/>/g' \
|
|
1094
|
+
-e 's/[0-9]*<&[0-9-]*/</g' \
|
|
1095
|
+
-e 's/||/;/g' \
|
|
1096
|
+
| tr '&;' '\n')" || git_segments_status=$?
|
|
1097
|
+
case "$git_segments_status" in
|
|
1098
|
+
0) ;;
|
|
1099
|
+
*) scan_failed "$git_segments_status" ;;
|
|
1100
|
+
esac
|
|
1038
1101
|
while IFS= read -r git_stmt; do
|
|
1039
1102
|
[ -n "$git_stmt" ] || continue
|
|
1040
1103
|
if ! scan -E "$git_stmt" "$RM_RF_CLUSTER" \
|
|
@@ -1044,7 +1107,7 @@ while IFS= read -r git_stmt; do
|
|
|
1044
1107
|
if scan -E "$git_stmt" "$GIT_CONTROL_PLANE"; then
|
|
1045
1108
|
block "recursive forced delete of the git control plane (.git holds every commit, branch and stash not already pushed; nothing in the working tree can rebuild it). Delete a specific ignored artifact instead, or re-clone if the checkout is genuinely to be discarded."
|
|
1046
1109
|
fi
|
|
1047
|
-
done <<<
|
|
1110
|
+
done <<<"$git_segments"
|
|
1048
1111
|
|
|
1049
1112
|
# 15. Credential stores. Reading one into an agent transcript is disclosure even
|
|
1050
1113
|
# when nothing is copied anywhere: the value lands in a log, a context window,
|
|
@@ -37,8 +37,30 @@ python3 .claude/skills/jira-journey/scripts/parse-plan.py <TICKET_ID>
|
|
|
37
37
|
|
|
38
38
|
The parser resolves Jira configuration from the checkout first: it searches
|
|
39
39
|
`$CLAUDE_PROJECT_DIR/.lisa/jira-cli/.config.yml`, then the current directory and
|
|
40
|
-
its parents, and
|
|
41
|
-
copy project settings into the home-level file just to make this
|
|
40
|
+
its parents, and uses `~/.config/.jira/.config.yml` when no checkout config
|
|
41
|
+
exists. Do not copy project settings into the home-level file just to make this
|
|
42
|
+
step work.
|
|
43
|
+
|
|
44
|
+
A checkout config is not automatically trusted. The first checkout candidate
|
|
45
|
+
that exists is decisive — that is `$CLAUDE_PROJECT_DIR` when it is set, which
|
|
46
|
+
outranks a config nearer the current directory, and only otherwise the nearest
|
|
47
|
+
one walking up from the current directory. Its server must match the operator's
|
|
48
|
+
trust root — `JIRA_SERVER` when set, otherwise the home config — and a mismatch
|
|
49
|
+
fails naming that file rather than quietly using the home config instead, so the
|
|
50
|
+
error points at the file that is actually wrong.
|
|
51
|
+
|
|
52
|
+
`server` must be a bare HTTPS origin: `https://host`, optionally with a port and
|
|
53
|
+
a single trailing slash. An explicit `:443` is accepted and canonicalized away,
|
|
54
|
+
so `https://host:443` and `https://host` are the same origin and either may be
|
|
55
|
+
configured. Userinfo (`https://user:token@…`), a query, a fragment, and any
|
|
56
|
+
other path are refused rather than trimmed away. So are a hostname this parser
|
|
57
|
+
cannot describe unambiguously — surrounding whitespace, control characters,
|
|
58
|
+
percent-encoded or non-ASCII hostnames, a trailing dot, and port 0.
|
|
59
|
+
The reason is that this value is both the trust key and the base every request
|
|
60
|
+
is built from; normalizing a richer URL down to its origin would approve one
|
|
61
|
+
value and then send the API token to a different one. Self-hosted context paths
|
|
62
|
+
and internationalized hostnames are out of scope and would need their own
|
|
63
|
+
base-path contract.
|
|
42
64
|
|
|
43
65
|
The script outputs JSON with: `ticket`, `prerequisites`, `steps`, `viewports`, `assertions`.
|
|
44
66
|
|
|
@@ -25,6 +25,7 @@ Output (JSON):
|
|
|
25
25
|
}
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
|
+
import ipaddress
|
|
28
29
|
import json
|
|
29
30
|
import os
|
|
30
31
|
import re
|
|
@@ -41,6 +42,24 @@ from urllib.parse import urlsplit
|
|
|
41
42
|
# a capture obligation.
|
|
42
43
|
LOCAL_EVIDENCE_PATTERN = re.compile(r'\[(SCREENSHOT|EVIDENCE):\s*([^\]]+)\]')
|
|
43
44
|
|
|
45
|
+
# One ASCII DNS name: dot-separated labels of letters, digits and inner hyphens.
|
|
46
|
+
# IPv4 literals are a subset of this form. Anything else -- percent-encoding, a
|
|
47
|
+
# trailing dot, an empty label, a leading or trailing hyphen -- is refused,
|
|
48
|
+
# because a hostname this pattern cannot describe is one whose meaning depends
|
|
49
|
+
# on who resolves it.
|
|
50
|
+
DNS_NAME_PATTERN = re.compile(
|
|
51
|
+
r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$'
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# The only two path spellings that mean "no path".
|
|
55
|
+
ROOT_PATHS = ("", "/")
|
|
56
|
+
|
|
57
|
+
DEFAULT_HTTPS_PORT = 443
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class JiraConfigError(Exception):
|
|
61
|
+
"""A configured Jira server could not be established as trusted."""
|
|
62
|
+
|
|
44
63
|
|
|
45
64
|
def jira_config_candidates():
|
|
46
65
|
"""Return project-first jira-cli config candidates without duplicates."""
|
|
@@ -80,63 +99,158 @@ def read_jira_config(config_path):
|
|
|
80
99
|
return server, login
|
|
81
100
|
|
|
82
101
|
|
|
102
|
+
def canonical_authority(netloc, hostname):
|
|
103
|
+
"""Return the canonical authority for one host, or "" when it is not one."""
|
|
104
|
+
if netloc.startswith("["):
|
|
105
|
+
try:
|
|
106
|
+
return f"[{ipaddress.IPv6Address(hostname).compressed}]"
|
|
107
|
+
except ValueError:
|
|
108
|
+
return ""
|
|
109
|
+
return hostname if DNS_NAME_PATTERN.match(hostname) else ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_bare_origin(server, parsed):
|
|
113
|
+
"""Report whether the raw value names an origin and nothing besides.
|
|
114
|
+
|
|
115
|
+
The query and fragment are checked against the raw string rather than the
|
|
116
|
+
parse, because ``https://host?`` and ``https://host`` parse to the same
|
|
117
|
+
empty query. A delimiter carrying nothing is still a component the operator
|
|
118
|
+
wrote and the trust key would discard.
|
|
119
|
+
"""
|
|
120
|
+
if "?" in server or "#" in server:
|
|
121
|
+
return False
|
|
122
|
+
if parsed.scheme != "https" or "@" in parsed.netloc:
|
|
123
|
+
return False
|
|
124
|
+
return parsed.path in ROOT_PATHS
|
|
125
|
+
|
|
126
|
+
|
|
83
127
|
def server_origin(server):
|
|
84
|
-
"""Return the
|
|
128
|
+
"""Return the canonical HTTPS origin, or "" when the value is not one.
|
|
129
|
+
|
|
130
|
+
This value is both the credential trust key and the base every request is
|
|
131
|
+
built from, so the two have to be the same string. A normalizer that reduced
|
|
132
|
+
an arbitrary URL to its origin would approve one value and send another:
|
|
133
|
+
userinfo, a query, a fragment, or a path would ride along into the request
|
|
134
|
+
while the trust check saw only the host, which is how an API token reaches a
|
|
135
|
+
URL that validation never approved. So the accepted grammar is a bare
|
|
136
|
+
origin -- ``https://host`` or ``https://host/`` with an optional non-default
|
|
137
|
+
port -- and every richer form is refused rather than trimmed down to fit.
|
|
138
|
+
|
|
139
|
+
Self-hosted context paths and internationalized hostnames are deliberately
|
|
140
|
+
out of scope: either needs its own base-path contract, and inventing one
|
|
141
|
+
here would re-open the gap between what is checked and what is sent.
|
|
142
|
+
"""
|
|
143
|
+
if not server or any(char <= " " or char >= "\x7f" for char in server):
|
|
144
|
+
return ""
|
|
85
145
|
try:
|
|
86
146
|
parsed = urlsplit(server)
|
|
87
147
|
port = parsed.port
|
|
88
148
|
except ValueError:
|
|
89
149
|
return ""
|
|
90
|
-
if parsed.
|
|
150
|
+
if not parsed.hostname or port == 0:
|
|
151
|
+
return ""
|
|
152
|
+
if not is_bare_origin(server, parsed):
|
|
91
153
|
return ""
|
|
92
|
-
|
|
154
|
+
authority = canonical_authority(parsed.netloc, parsed.hostname)
|
|
155
|
+
if not authority:
|
|
93
156
|
return ""
|
|
94
|
-
|
|
95
|
-
authority = f"[{hostname}]" if ":" in hostname else hostname
|
|
96
|
-
if port is not None and port != 443:
|
|
157
|
+
if port is not None and port != DEFAULT_HTTPS_PORT:
|
|
97
158
|
authority = f"{authority}:{port}"
|
|
98
159
|
return f"https://{authority}"
|
|
99
160
|
|
|
100
161
|
|
|
101
|
-
def
|
|
102
|
-
"""
|
|
162
|
+
def resolve_trusted_origin():
|
|
163
|
+
"""Return the operator-owned origin a checkout config must match, or "".
|
|
164
|
+
|
|
165
|
+
An explicit JIRA_SERVER wins over the home config. A JIRA_SERVER that is not
|
|
166
|
+
a canonical origin raises rather than resolving to "": degrading a malformed
|
|
167
|
+
trust root into "no trust root" would silently widen what a checkout config
|
|
168
|
+
is allowed to claim, which is the opposite of what setting it asked for.
|
|
169
|
+
"""
|
|
170
|
+
configured = os.environ.get("JIRA_SERVER", "")
|
|
171
|
+
if configured.strip():
|
|
172
|
+
origin = server_origin(configured)
|
|
173
|
+
if not origin:
|
|
174
|
+
raise JiraConfigError(
|
|
175
|
+
"JIRA_SERVER must be a valid HTTPS URL naming one origin, with "
|
|
176
|
+
"no userinfo, path, query or fragment"
|
|
177
|
+
)
|
|
178
|
+
return origin
|
|
179
|
+
|
|
180
|
+
home_config = (Path.home() / ".config" / ".jira" / ".config.yml").resolve()
|
|
181
|
+
if not home_config.exists():
|
|
182
|
+
return ""
|
|
183
|
+
return server_origin(read_jira_config(home_config)[0])
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def resolve_jira_config_path(trusted_origin=None):
|
|
187
|
+
"""Select the jira-cli config that governs this checkout.
|
|
188
|
+
|
|
189
|
+
The nearest checkout-owned config is decisive. When one exists it must match
|
|
190
|
+
the operator's trust root, and a mismatch fails naming that file. Falling
|
|
191
|
+
through to the home config instead would report a missing or unusable home
|
|
192
|
+
config for a project config that was found and refused -- an error about the
|
|
193
|
+
wrong file, pointing whoever has to fix it away from the one that is wrong.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
trusted_origin: The operator's trust root, resolved when not supplied.
|
|
197
|
+
"""
|
|
103
198
|
candidates = jira_config_candidates()
|
|
104
199
|
home_config = candidates[-1]
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
trusted_server, _ = read_jira_config(home_config)
|
|
108
|
-
trusted_origin = server_origin(trusted_server)
|
|
200
|
+
if trusted_origin is None:
|
|
201
|
+
trusted_origin = resolve_trusted_origin()
|
|
109
202
|
|
|
110
203
|
for candidate in candidates[:-1]:
|
|
111
204
|
if not candidate.exists():
|
|
112
205
|
continue
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
206
|
+
if not trusted_origin:
|
|
207
|
+
raise JiraConfigError(
|
|
208
|
+
f"jira-cli config {candidate} cannot be trusted: neither "
|
|
209
|
+
"JIRA_SERVER nor a home config establishes a trust root"
|
|
210
|
+
)
|
|
211
|
+
if server_origin(read_jira_config(candidate)[0]) != trusted_origin:
|
|
212
|
+
raise JiraConfigError(
|
|
213
|
+
f"jira-cli config {candidate} server does not match the "
|
|
214
|
+
"trusted JIRA server origin"
|
|
215
|
+
)
|
|
216
|
+
return candidate
|
|
116
217
|
return home_config
|
|
117
218
|
|
|
118
219
|
|
|
119
220
|
def get_jira_config():
|
|
120
|
-
"""Read JIRA server and login from the project-first jira-cli config.
|
|
121
|
-
|
|
221
|
+
"""Read JIRA server and login from the project-first jira-cli config.
|
|
222
|
+
|
|
223
|
+
Every exit below happens before the token is read, so a configured URL that
|
|
224
|
+
fails validation cannot reach a request. The returned server is the
|
|
225
|
+
canonical origin, never the configured string, which is what keeps the value
|
|
226
|
+
that was checked and the value that is sent identical.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
trusted_origin = resolve_trusted_origin()
|
|
230
|
+
config_path = resolve_jira_config_path(trusted_origin)
|
|
231
|
+
except JiraConfigError as error:
|
|
232
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
|
|
122
235
|
if not config_path.exists():
|
|
123
236
|
print(f"ERROR: jira-cli config not found at {config_path}", file=sys.stderr)
|
|
124
237
|
sys.exit(1)
|
|
125
238
|
|
|
126
239
|
server, login = read_jira_config(config_path)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
240
|
+
origin = server_origin(server)
|
|
241
|
+
if not origin:
|
|
242
|
+
print(
|
|
243
|
+
f"ERROR: JIRA server in {config_path} must be an HTTPS URL naming "
|
|
244
|
+
"one origin, with no userinfo, path, query or fragment",
|
|
245
|
+
file=sys.stderr,
|
|
246
|
+
)
|
|
132
247
|
sys.exit(1)
|
|
133
|
-
if trusted_origin:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
print("ERROR: JIRA server must be an HTTPS URL from a trusted config", file=sys.stderr)
|
|
248
|
+
if trusted_origin and origin != trusted_origin:
|
|
249
|
+
print(
|
|
250
|
+
f"ERROR: jira-cli config {config_path} server does not match the "
|
|
251
|
+
"trusted JIRA server origin",
|
|
252
|
+
file=sys.stderr,
|
|
253
|
+
)
|
|
140
254
|
sys.exit(1)
|
|
141
255
|
|
|
142
256
|
token = os.environ.get("JIRA_API_TOKEN", "")
|
|
@@ -144,7 +258,7 @@ def get_jira_config():
|
|
|
144
258
|
print("ERROR: JIRA_API_TOKEN env var not set", file=sys.stderr)
|
|
145
259
|
sys.exit(1)
|
|
146
260
|
|
|
147
|
-
return
|
|
261
|
+
return origin, login, token
|
|
148
262
|
|
|
149
263
|
|
|
150
264
|
def fetch_ticket(server, login, token, ticket_id):
|
|
@@ -1035,6 +1035,69 @@ fi
|
|
|
1035
1035
|
# purpose, and blocking those would be the collision that gets a guard switched
|
|
1036
1036
|
# off. The trailing `([/[:space:]'"$qc"']|$)` is what draws that line.
|
|
1037
1037
|
readonly GIT_CONTROL_PLANE='(^|[[:space:]='"$qc"'./])\.git([/[:space:]'"$qc"']|$)'
|
|
1038
|
+
# This guard is the one segment walk that pairs TWO facts found in different
|
|
1039
|
+
# places: the protected target (`.git`) and the destructive verb (a recursive
|
|
1040
|
+
# forced delete). That makes its segmentation load-bearing in a way the rm and
|
|
1041
|
+
# push walks' is not — those two read a verb and its own operands, which are
|
|
1042
|
+
# always in one pipeline stage, so splitting on `|` costs them nothing.
|
|
1043
|
+
#
|
|
1044
|
+
# Here it cost everything. The old feeder was `tr '&|;' '\n'`, which cut a
|
|
1045
|
+
# PIPELINE into pieces before the pairing question was asked, and a pipeline is
|
|
1046
|
+
# exactly how a delete reaches a target named upstream of it:
|
|
1047
|
+
# `find .git -type f -print0 | xargs -0 rm -rf` became `find .git -type f
|
|
1048
|
+
# -print0` (a target, no verb) and `xargs -0 rm -rf` (a verb, no target), and
|
|
1049
|
+
# neither half could answer. Measured before this fix, with the blanket
|
|
1050
|
+
# `xargs rm -rf` rule masking the reported shape: `find .git -type d | parallel
|
|
1051
|
+
# rm -rf`, `… | /usr/bin/xargs rm -rf`, `… | xargs -0 -- rm --recursive
|
|
1052
|
+
# --force`, `… 2>&1 | parallel rm -rf`, `… |& parallel rm -rf` and
|
|
1053
|
+
# `cat .git/config | grep url | rm -rf refs` were ALL allowed (CodySwannGT/lisa#3320).
|
|
1054
|
+
#
|
|
1055
|
+
# So segment on STATEMENT boundaries and nothing else. A pipe joins stages of
|
|
1056
|
+
# one statement; `;`, `&`, `&&` and `||` end one. Three spellings have to be
|
|
1057
|
+
# normalized first or a naive split gets them wrong in both directions:
|
|
1058
|
+
#
|
|
1059
|
+
# `|&` bash's pipe-with-stderr. Splitting its `&` would halve a pipeline —
|
|
1060
|
+
# the same fail-open, wearing a different operator. Becomes `|`.
|
|
1061
|
+
# `2>&1` a redirection, not a boundary (also `>&2`, `<&3`, `&>file`). These
|
|
1062
|
+
# are where `&` appears WITHOUT ending a statement, and they are
|
|
1063
|
+
# common enough that ignoring them would reopen the hole on any
|
|
1064
|
+
# pipeline that redirects stderr. The `&` is folded away.
|
|
1065
|
+
# `||` a real boundary that happens to be built from pipe characters, so it
|
|
1066
|
+
# must be recognized BEFORE a single `|` is spared. Becomes `;`.
|
|
1067
|
+
#
|
|
1068
|
+
# Every one of those rewrites only ever REMOVES a boundary or leaves it intact,
|
|
1069
|
+
# so each can merge two segments but never split one — the fail-closed
|
|
1070
|
+
# direction. `&` and `;` still separate, so unrelated commands are not joined.
|
|
1071
|
+
#
|
|
1072
|
+
# The leading awk is the same idea one level up. A line ending in `|` is bash's
|
|
1073
|
+
# OTHER implicit continuation — the pipeline resumes on the next line — and the
|
|
1074
|
+
# per-line read below would otherwise part the stages exactly as `tr` did, so
|
|
1075
|
+
# `find .git -type d |`↵` parallel rm -rf` fails open through the newline
|
|
1076
|
+
# instead of through the pipe. This mirrors the backslash-continuation join that
|
|
1077
|
+
# `normalized_command_str` already performs for the same reason. It is done here
|
|
1078
|
+
# rather than in that shared normalization deliberately: this guard is the one
|
|
1079
|
+
# that pairs facts across stages, so it is the one that needs the stages kept
|
|
1080
|
+
# together, and widening the shared normalization would change every other
|
|
1081
|
+
# guard's segmentation for no defect anyone has measured.
|
|
1082
|
+
#
|
|
1083
|
+
# The status check is the second half of the fix and closes a quieter hole. This
|
|
1084
|
+
# feeder had none, while the rm and push walks above both have one: a segmenter
|
|
1085
|
+
# that could not run produced no lines, the loop body never executed, and the
|
|
1086
|
+
# guard was skipped with the command ALLOWED. A segmentation that cannot answer
|
|
1087
|
+
# must refuse, exactly like a scan that cannot answer.
|
|
1088
|
+
git_segments_status=0
|
|
1089
|
+
git_segments="$(printf '%s' "$normalized_command_str" \
|
|
1090
|
+
| awk '{ if (/\|[ \t]*$/) printf "%s ", $0; else print }' \
|
|
1091
|
+
| sed -e 's/|&/|/g' \
|
|
1092
|
+
-e 's/&>/>/g' \
|
|
1093
|
+
-e 's/[0-9]*>&[0-9-]*/>/g' \
|
|
1094
|
+
-e 's/[0-9]*<&[0-9-]*/</g' \
|
|
1095
|
+
-e 's/||/;/g' \
|
|
1096
|
+
| tr '&;' '\n')" || git_segments_status=$?
|
|
1097
|
+
case "$git_segments_status" in
|
|
1098
|
+
0) ;;
|
|
1099
|
+
*) scan_failed "$git_segments_status" ;;
|
|
1100
|
+
esac
|
|
1038
1101
|
while IFS= read -r git_stmt; do
|
|
1039
1102
|
[ -n "$git_stmt" ] || continue
|
|
1040
1103
|
if ! scan -E "$git_stmt" "$RM_RF_CLUSTER" \
|
|
@@ -1044,7 +1107,7 @@ while IFS= read -r git_stmt; do
|
|
|
1044
1107
|
if scan -E "$git_stmt" "$GIT_CONTROL_PLANE"; then
|
|
1045
1108
|
block "recursive forced delete of the git control plane (.git holds every commit, branch and stash not already pushed; nothing in the working tree can rebuild it). Delete a specific ignored artifact instead, or re-clone if the checkout is genuinely to be discarded."
|
|
1046
1109
|
fi
|
|
1047
|
-
done <<<
|
|
1110
|
+
done <<<"$git_segments"
|
|
1048
1111
|
|
|
1049
1112
|
# 15. Credential stores. Reading one into an agent transcript is disclosure even
|
|
1050
1113
|
# when nothing is copied anywhere: the value lands in a log, a context window,
|
|
@@ -37,8 +37,30 @@ python3 .claude/skills/jira-journey/scripts/parse-plan.py <TICKET_ID>
|
|
|
37
37
|
|
|
38
38
|
The parser resolves Jira configuration from the checkout first: it searches
|
|
39
39
|
`$CLAUDE_PROJECT_DIR/.lisa/jira-cli/.config.yml`, then the current directory and
|
|
40
|
-
its parents, and
|
|
41
|
-
copy project settings into the home-level file just to make this
|
|
40
|
+
its parents, and uses `~/.config/.jira/.config.yml` when no checkout config
|
|
41
|
+
exists. Do not copy project settings into the home-level file just to make this
|
|
42
|
+
step work.
|
|
43
|
+
|
|
44
|
+
A checkout config is not automatically trusted. The first checkout candidate
|
|
45
|
+
that exists is decisive — that is `$CLAUDE_PROJECT_DIR` when it is set, which
|
|
46
|
+
outranks a config nearer the current directory, and only otherwise the nearest
|
|
47
|
+
one walking up from the current directory. Its server must match the operator's
|
|
48
|
+
trust root — `JIRA_SERVER` when set, otherwise the home config — and a mismatch
|
|
49
|
+
fails naming that file rather than quietly using the home config instead, so the
|
|
50
|
+
error points at the file that is actually wrong.
|
|
51
|
+
|
|
52
|
+
`server` must be a bare HTTPS origin: `https://host`, optionally with a port and
|
|
53
|
+
a single trailing slash. An explicit `:443` is accepted and canonicalized away,
|
|
54
|
+
so `https://host:443` and `https://host` are the same origin and either may be
|
|
55
|
+
configured. Userinfo (`https://user:token@…`), a query, a fragment, and any
|
|
56
|
+
other path are refused rather than trimmed away. So are a hostname this parser
|
|
57
|
+
cannot describe unambiguously — surrounding whitespace, control characters,
|
|
58
|
+
percent-encoded or non-ASCII hostnames, a trailing dot, and port 0.
|
|
59
|
+
The reason is that this value is both the trust key and the base every request
|
|
60
|
+
is built from; normalizing a richer URL down to its origin would approve one
|
|
61
|
+
value and then send the API token to a different one. Self-hosted context paths
|
|
62
|
+
and internationalized hostnames are out of scope and would need their own
|
|
63
|
+
base-path contract.
|
|
42
64
|
|
|
43
65
|
The script outputs JSON with: `ticket`, `prerequisites`, `steps`, `viewports`, `assertions`.
|
|
44
66
|
|