@paths.design/caws-cli 11.1.4 → 11.1.6
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/dist/index.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paths.design/caws-cli",
|
|
3
|
-
"version": "11.1.
|
|
3
|
+
"version": "11.1.6",
|
|
4
4
|
"description": "CAWS CLI - the governed core for CAWS project state, scope, claims, gates, waivers, and evidence (v11.1). Restores canonical spec/worktree lifecycle on the vNext kernel/store/shell architecture.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -47,9 +47,24 @@ SAFE_DELETE_PREFIXES: list[str] = [
|
|
|
47
47
|
# Pipeline-aware deny patterns: matched against the FULL raw command string
|
|
48
48
|
# BEFORE segmentation. These detect cross-pipeline dangers like curl|sh and
|
|
49
49
|
# fork bombs whose syntax spans segment boundaries.
|
|
50
|
+
#
|
|
51
|
+
# IMPORTANT: these patterns are matched against `executable_surface`, which
|
|
52
|
+
# has had quoted regions (single + double quotes) replaced with spaces by
|
|
53
|
+
# strip_quoted_regions. That is what gives us quote-safety — a literal
|
|
54
|
+
# `tail x | sh` inside a quoted string is replaced with whitespace before
|
|
55
|
+
# the regex runs, so it does not trigger.
|
|
50
56
|
DENY_PIPELINE_PATTERNS: list[tuple[str, str]] = [
|
|
51
|
-
# Pipe-to-shell (network exfiltration) —
|
|
52
|
-
|
|
57
|
+
# Pipe-to-shell (network exfiltration) — historical curl/wget pattern,
|
|
58
|
+
# kept for diagnostic-reason specificity. The generic rule below also
|
|
59
|
+
# catches these; this pattern fires first to give a clearer reason.
|
|
60
|
+
(r"\b(curl|wget)\b[^|]*\|\s*(ba)?sh\b", "pipe-to-shell execution"),
|
|
61
|
+
# Generic pipe-to-shell — any command piped into bash/sh execution.
|
|
62
|
+
# Catches `tail x | sh`, `cat script | bash`, `head install.sh | sh`,
|
|
63
|
+
# etc. The leading `[^|]` (non-pipe, non-empty char before the pipe)
|
|
64
|
+
# ensures we do not false-match `||` (logical OR). The trailing `\b`
|
|
65
|
+
# ensures we do not match `bash-completion` or similar word-extended
|
|
66
|
+
# forms. Quote-safety is provided by strip_quoted_regions upstream.
|
|
67
|
+
(r"[^|]\|\s*(ba)?sh\b", "generic pipe-to-shell execution — pipe target is a shell interpreter"),
|
|
53
68
|
# Fork bombs — special syntax that segmentation mangles
|
|
54
69
|
(r":\(\)\s*\{.*:\|:.*\}\s*;\s*:", "fork bomb"),
|
|
55
70
|
(r"\bwhile\s+true\b.*\bfork\b", "fork loop"),
|
|
@@ -141,6 +156,83 @@ SHELL_C_WRAPPERS: set[str] = {
|
|
|
141
156
|
}
|
|
142
157
|
|
|
143
158
|
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# DANGER-LATCH-CALIBRATION-001 — explicit allow-list of read-only surface
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Hybrid fail-closed semantics: for `git`, `gh`, and `npm` (the three
|
|
163
|
+
# governed command families), a subcommand NOT on the allow-list and NOT
|
|
164
|
+
# matched by any deny/confirm pattern resolves to "ask". Other commands
|
|
165
|
+
# keep the existing classifier default. The allow-list itself is
|
|
166
|
+
# command+subcommand specific — `gh pr view` is admitted, `gh pr merge`
|
|
167
|
+
# is not (it falls through to ask). See spec invariant for the full
|
|
168
|
+
# canonical surface.
|
|
169
|
+
#
|
|
170
|
+
# IMPORTANT: editing this allow-list weakens or expands the agent-
|
|
171
|
+
# boundary guard. Add a name only after confirming it is observational
|
|
172
|
+
# (no filesystem mutation, no network write, no privileged operation,
|
|
173
|
+
# no irreversible state change to GitHub / npm / git).
|
|
174
|
+
|
|
175
|
+
# Top-level simple commands that are read-only file inspection or search.
|
|
176
|
+
# Matched by exact basename of the segment's first executable token.
|
|
177
|
+
# These do NOT have subcommands worth tracking; presence of the name is
|
|
178
|
+
# enough.
|
|
179
|
+
ALLOWED_SIMPLE_COMMANDS: set[str] = {
|
|
180
|
+
# File inspection
|
|
181
|
+
"tail", "head", "cat", "less", "more", "wc", "stat", "file",
|
|
182
|
+
"du", "df", "ls", "tree",
|
|
183
|
+
# Search
|
|
184
|
+
"grep", "rg",
|
|
185
|
+
# `find` is NOT in this set because of -delete/-exec/-execdir/-fprint/-ok;
|
|
186
|
+
# classify_allow_list handles find specifically.
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Allowed git subcommands (read-only).
|
|
190
|
+
ALLOWED_GIT_SUBCOMMANDS: set[str] = {
|
|
191
|
+
"status", "log", "diff", "show", "branch", "tag",
|
|
192
|
+
"remote", "config", "rev-parse", "ls-files", "blame",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# Allowed gh top-level groups + subcommands. Format: "group action".
|
|
196
|
+
# Example: "pr view" means `gh pr view ...` is admitted.
|
|
197
|
+
# `gh api` is handled specifically by gh_api_method() because its
|
|
198
|
+
# admit decision depends on -X verb, not subcommand structure.
|
|
199
|
+
ALLOWED_GH_ACTIONS: set[str] = {
|
|
200
|
+
"pr view", "pr status", "pr list", "pr checks", "pr diff",
|
|
201
|
+
"run view", "run list",
|
|
202
|
+
"issue view", "issue list",
|
|
203
|
+
"repo view",
|
|
204
|
+
"release view", "release list",
|
|
205
|
+
# `gh api` admitted only when -X is absent or -X is GET (separate logic)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Mutating gh actions that the allow-list explicitly REJECTS (returns ask).
|
|
209
|
+
# These exist because `gh` as a top-level command is not whitelisted; only
|
|
210
|
+
# named read-only subcommands are. But for clarity (and to surface a
|
|
211
|
+
# specific reason in diagnostics), we name the mutating ones explicitly.
|
|
212
|
+
# Note: appearance here does NOT make the command a hard deny — only ask.
|
|
213
|
+
GH_MUTATING_ACTIONS: set[str] = {
|
|
214
|
+
"pr merge", "pr edit", "pr close", "pr reopen", "pr ready",
|
|
215
|
+
"pr comment", "pr review",
|
|
216
|
+
"run rerun", "run cancel",
|
|
217
|
+
"workflow run", "workflow enable", "workflow disable",
|
|
218
|
+
"release create", "release edit", "release delete",
|
|
219
|
+
"issue create", "issue edit", "issue close",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# Allowed npm subcommands (read-only).
|
|
223
|
+
ALLOWED_NPM_SUBCOMMANDS: set[str] = {
|
|
224
|
+
"view", "whoami", "config", "ls", "outdated", "explain",
|
|
225
|
+
# `npm pack --dry-run` is admitted; bare `npm pack` is not (handled separately)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# Top-level command names that participate in HYBRID FAIL-CLOSED semantics.
|
|
229
|
+
# For commands in this set, an unknown subcommand (one not on the
|
|
230
|
+
# allow-list and not matched by any deny/confirm pattern) resolves to
|
|
231
|
+
# "ask", not the default "allow". Outside this set, the classifier's
|
|
232
|
+
# global default applies.
|
|
233
|
+
GOVERNED_FAMILIES: set[str] = {"git", "gh", "npm"}
|
|
234
|
+
|
|
235
|
+
|
|
144
236
|
# ---------------------------------------------------------------------------
|
|
145
237
|
# Command segmentation
|
|
146
238
|
# ---------------------------------------------------------------------------
|
|
@@ -517,6 +609,324 @@ def classify_git_semantics(
|
|
|
517
609
|
return None
|
|
518
610
|
|
|
519
611
|
|
|
612
|
+
# ---------------------------------------------------------------------------
|
|
613
|
+
# DANGER-LATCH-CALIBRATION-001 — gh / npm subcommand detection
|
|
614
|
+
# ---------------------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
def detect_gh_subcommand(segment: str) -> tuple[str, str] | None:
|
|
617
|
+
"""Detect the (group, action) for a `gh` segment.
|
|
618
|
+
|
|
619
|
+
Returns a tuple like ("pr", "view") for `gh pr view 5`, or
|
|
620
|
+
("api", "") for `gh api /repos/foo/bar`. Returns None if the
|
|
621
|
+
segment is not a gh invocation. Handles env/time/nohup wrappers
|
|
622
|
+
via normalize_command_tokens.
|
|
623
|
+
|
|
624
|
+
Subcommand-with-no-action (e.g., bare `gh pr`) returns
|
|
625
|
+
(group, "") so the caller can distinguish "no allow-list match"
|
|
626
|
+
from "not a gh command at all."
|
|
627
|
+
"""
|
|
628
|
+
try:
|
|
629
|
+
tokens = shlex.split(segment)
|
|
630
|
+
except ValueError:
|
|
631
|
+
return None
|
|
632
|
+
|
|
633
|
+
if not tokens:
|
|
634
|
+
return None
|
|
635
|
+
|
|
636
|
+
start, nested = normalize_command_tokens(tokens)
|
|
637
|
+
if nested is not None:
|
|
638
|
+
return None
|
|
639
|
+
if start >= len(tokens) or command_basename(tokens[start]) != "gh":
|
|
640
|
+
return None
|
|
641
|
+
|
|
642
|
+
# First non-flag token after `gh` is the group (pr, run, issue, repo, api, ...)
|
|
643
|
+
i = start + 1
|
|
644
|
+
group = ""
|
|
645
|
+
while i < len(tokens):
|
|
646
|
+
tok = tokens[i]
|
|
647
|
+
if tok.startswith("-"):
|
|
648
|
+
i += 1
|
|
649
|
+
continue
|
|
650
|
+
group = tok
|
|
651
|
+
i += 1
|
|
652
|
+
break
|
|
653
|
+
|
|
654
|
+
if not group:
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
# Second non-flag token is the action (view, list, merge, ...)
|
|
658
|
+
# For `gh api` there is no action — the next token is the path.
|
|
659
|
+
if group == "api":
|
|
660
|
+
return ("api", "")
|
|
661
|
+
|
|
662
|
+
action = ""
|
|
663
|
+
while i < len(tokens):
|
|
664
|
+
tok = tokens[i]
|
|
665
|
+
if tok.startswith("-"):
|
|
666
|
+
i += 1
|
|
667
|
+
continue
|
|
668
|
+
action = tok
|
|
669
|
+
break
|
|
670
|
+
|
|
671
|
+
return (group, action)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def gh_api_method(segment: str) -> str:
|
|
675
|
+
"""Return the HTTP method for a `gh api` segment.
|
|
676
|
+
|
|
677
|
+
Defaults to "GET" when no -X flag is present. Recognizes both
|
|
678
|
+
`-X POST` and `--method POST` / `-XPOST` forms.
|
|
679
|
+
"""
|
|
680
|
+
try:
|
|
681
|
+
tokens = shlex.split(segment)
|
|
682
|
+
except ValueError:
|
|
683
|
+
return "GET"
|
|
684
|
+
|
|
685
|
+
for i, tok in enumerate(tokens):
|
|
686
|
+
if tok in ("-X", "--method") and i + 1 < len(tokens):
|
|
687
|
+
return tokens[i + 1].upper()
|
|
688
|
+
if tok.startswith("-X") and len(tok) > 2:
|
|
689
|
+
# Concatenated form like -XPOST
|
|
690
|
+
return tok[2:].upper()
|
|
691
|
+
if tok.startswith("--method="):
|
|
692
|
+
return tok.split("=", 1)[1].upper()
|
|
693
|
+
return "GET"
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def detect_npm_subcommand(segment: str) -> str | None:
|
|
697
|
+
"""Return the subcommand for an `npm` segment, or None if not npm.
|
|
698
|
+
|
|
699
|
+
Handles env/time/nohup wrappers via normalize_command_tokens.
|
|
700
|
+
"""
|
|
701
|
+
try:
|
|
702
|
+
tokens = shlex.split(segment)
|
|
703
|
+
except ValueError:
|
|
704
|
+
return None
|
|
705
|
+
|
|
706
|
+
if not tokens:
|
|
707
|
+
return None
|
|
708
|
+
|
|
709
|
+
start, nested = normalize_command_tokens(tokens)
|
|
710
|
+
if nested is not None:
|
|
711
|
+
return None
|
|
712
|
+
if start >= len(tokens) or command_basename(tokens[start]) != "npm":
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
i = start + 1
|
|
716
|
+
while i < len(tokens):
|
|
717
|
+
tok = tokens[i]
|
|
718
|
+
if tok.startswith("-"):
|
|
719
|
+
i += 1
|
|
720
|
+
continue
|
|
721
|
+
return tok
|
|
722
|
+
return None
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def npm_pack_is_dry_run(segment: str) -> bool:
|
|
726
|
+
"""Return true if `npm pack` invocation has --dry-run."""
|
|
727
|
+
try:
|
|
728
|
+
tokens = shlex.split(segment)
|
|
729
|
+
except ValueError:
|
|
730
|
+
return False
|
|
731
|
+
return "--dry-run" in tokens
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ---------------------------------------------------------------------------
|
|
735
|
+
# DANGER-LATCH-CALIBRATION-001 — allow-list classifier
|
|
736
|
+
# ---------------------------------------------------------------------------
|
|
737
|
+
|
|
738
|
+
def classify_allow_list(segment: str) -> tuple[str, str] | None:
|
|
739
|
+
"""Return ("allow", "") if the segment is on the documented allow-list.
|
|
740
|
+
|
|
741
|
+
The allow-list is consulted AFTER deny/confirm patterns. A segment
|
|
742
|
+
that matches a deny or confirm pattern WILL escalate the overall
|
|
743
|
+
decision; this function is only what the segment contributes when
|
|
744
|
+
no other rule fires.
|
|
745
|
+
|
|
746
|
+
Returns None when the segment does not match the allow-list. The
|
|
747
|
+
caller (classify_command) then falls through to either:
|
|
748
|
+
(a) the existing classifier default (allow) for non-governed
|
|
749
|
+
commands, or
|
|
750
|
+
(b) hybrid fail-closed "ask" via
|
|
751
|
+
classify_governed_family_default for git/gh/npm.
|
|
752
|
+
"""
|
|
753
|
+
# First, the simple-command allow-list — match by extracted command word.
|
|
754
|
+
cmd = extract_command_word(segment)
|
|
755
|
+
if not cmd:
|
|
756
|
+
return None
|
|
757
|
+
|
|
758
|
+
# Strip path components so /usr/bin/tail matches `tail`.
|
|
759
|
+
cmd_base = command_basename(cmd)
|
|
760
|
+
|
|
761
|
+
# `find` is allowed ONLY without mutating action flags. The
|
|
762
|
+
# classify_find_delete function returns ("ask", ...) when find has
|
|
763
|
+
# any of -delete/-exec/-execdir/-fprint/-ok. The allow-list admits
|
|
764
|
+
# find here only when classify_find_delete would return None (i.e.,
|
|
765
|
+
# the find is observational).
|
|
766
|
+
if cmd_base == "find":
|
|
767
|
+
if classify_find_delete(segment) is None:
|
|
768
|
+
return ("allow", "")
|
|
769
|
+
return None
|
|
770
|
+
|
|
771
|
+
if cmd_base in ALLOWED_SIMPLE_COMMANDS:
|
|
772
|
+
return ("allow", "")
|
|
773
|
+
|
|
774
|
+
# Read-only git subcommands.
|
|
775
|
+
if cmd_base == "git":
|
|
776
|
+
sub = detect_git_subcommand(segment)
|
|
777
|
+
if sub is None:
|
|
778
|
+
# Bare `git` with no subcommand — not allow-list eligible.
|
|
779
|
+
return None
|
|
780
|
+
# Special-case `git tag --list` / `git tag -l` — list only,
|
|
781
|
+
# not mutating (`git tag -d` is a delete).
|
|
782
|
+
if sub == "tag":
|
|
783
|
+
try:
|
|
784
|
+
tokens = shlex.split(segment)
|
|
785
|
+
except ValueError:
|
|
786
|
+
return None
|
|
787
|
+
# Admit only when --list or -l is present and no -d/-D/-m.
|
|
788
|
+
mutating = any(t in ("-d", "-D", "-m", "-a", "-s", "-f") for t in tokens)
|
|
789
|
+
listing = any(t in ("--list", "-l") for t in tokens)
|
|
790
|
+
if listing and not mutating:
|
|
791
|
+
return ("allow", "")
|
|
792
|
+
return None
|
|
793
|
+
# Special-case `git branch` — read-only when no -d/-D/-m flags.
|
|
794
|
+
if sub == "branch":
|
|
795
|
+
try:
|
|
796
|
+
tokens = shlex.split(segment)
|
|
797
|
+
except ValueError:
|
|
798
|
+
return None
|
|
799
|
+
mutating = any(
|
|
800
|
+
t in ("-d", "-D", "-m", "-M", "--delete", "--move") for t in tokens
|
|
801
|
+
)
|
|
802
|
+
if not mutating:
|
|
803
|
+
return ("allow", "")
|
|
804
|
+
return None
|
|
805
|
+
# Special-case `git config --get` — read-only get; bare `git config`
|
|
806
|
+
# or `git config key value` is mutating.
|
|
807
|
+
if sub == "config":
|
|
808
|
+
try:
|
|
809
|
+
tokens = shlex.split(segment)
|
|
810
|
+
except ValueError:
|
|
811
|
+
return None
|
|
812
|
+
if any(t in ("--get", "--get-all", "--get-regexp", "--list", "-l") for t in tokens):
|
|
813
|
+
return ("allow", "")
|
|
814
|
+
return None
|
|
815
|
+
if sub in ALLOWED_GIT_SUBCOMMANDS:
|
|
816
|
+
return ("allow", "")
|
|
817
|
+
return None
|
|
818
|
+
|
|
819
|
+
# Read-only gh subcommands.
|
|
820
|
+
if cmd_base == "gh":
|
|
821
|
+
result = detect_gh_subcommand(segment)
|
|
822
|
+
if result is None:
|
|
823
|
+
return None
|
|
824
|
+
group, action = result
|
|
825
|
+
# `gh api` is admit-only when method is GET.
|
|
826
|
+
if group == "api":
|
|
827
|
+
if gh_api_method(segment) == "GET":
|
|
828
|
+
return ("allow", "")
|
|
829
|
+
return None
|
|
830
|
+
key = f"{group} {action}".strip()
|
|
831
|
+
if key in ALLOWED_GH_ACTIONS:
|
|
832
|
+
return ("allow", "")
|
|
833
|
+
return None
|
|
834
|
+
|
|
835
|
+
# Read-only npm subcommands.
|
|
836
|
+
if cmd_base == "npm":
|
|
837
|
+
sub = detect_npm_subcommand(segment)
|
|
838
|
+
if sub is None:
|
|
839
|
+
return None
|
|
840
|
+
if sub == "pack":
|
|
841
|
+
if npm_pack_is_dry_run(segment):
|
|
842
|
+
return ("allow", "")
|
|
843
|
+
return None
|
|
844
|
+
if sub in ALLOWED_NPM_SUBCOMMANDS:
|
|
845
|
+
return ("allow", "")
|
|
846
|
+
return None
|
|
847
|
+
|
|
848
|
+
return None
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def classify_governed_family_default(segment: str) -> tuple[str, str] | None:
|
|
852
|
+
"""Hybrid fail-closed for git/gh/npm — unknown subcommand → ask.
|
|
853
|
+
|
|
854
|
+
Only fires when:
|
|
855
|
+
- the segment's command is in GOVERNED_FAMILIES, OR is a
|
|
856
|
+
hyphenated PATH-spoof variant (e.g. `gh-pr-view-fake-script`,
|
|
857
|
+
`git-foo`, `npm-something`) that could be mistaken for a
|
|
858
|
+
governed-family command,
|
|
859
|
+
- no deny/confirm pattern matched,
|
|
860
|
+
- no allow-list match.
|
|
861
|
+
Returns ("ask", reason) in that case, None otherwise.
|
|
862
|
+
|
|
863
|
+
Callers should invoke this AFTER deny/confirm/allow-list checks
|
|
864
|
+
have all returned no opinion. The function's job is the third
|
|
865
|
+
tier of decision-making for governed families.
|
|
866
|
+
|
|
867
|
+
The hyphenated-variant check enforces the spec's anchoring
|
|
868
|
+
invariant: `gh-pr-view-fake-script` does not match the `gh pr view`
|
|
869
|
+
allow-list, and because it shadows a governed family name, it
|
|
870
|
+
deserves the same ask-by-default treatment a real `gh` invocation
|
|
871
|
+
would get for an unknown subcommand.
|
|
872
|
+
"""
|
|
873
|
+
cmd = extract_command_word(segment)
|
|
874
|
+
if not cmd:
|
|
875
|
+
return None
|
|
876
|
+
cmd_base = command_basename(cmd)
|
|
877
|
+
|
|
878
|
+
# PATH-spoof variants — gh-foo, git-foo, npm-foo. Treated as a
|
|
879
|
+
# suspicious hyphenated impersonation of a governed family, not
|
|
880
|
+
# as an unknown command. The reason names the spoof explicitly.
|
|
881
|
+
for family in GOVERNED_FAMILIES:
|
|
882
|
+
if cmd_base.startswith(f"{family}-"):
|
|
883
|
+
return (
|
|
884
|
+
"ask",
|
|
885
|
+
f"hyphenated command `{cmd_base}` shadows governed family "
|
|
886
|
+
f"`{family}` — not on the allow-list; ask before invoking "
|
|
887
|
+
"to confirm this is not a PATH-spoof of `{family}`",
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
if cmd_base not in GOVERNED_FAMILIES:
|
|
891
|
+
return None
|
|
892
|
+
|
|
893
|
+
# Surface a useful reason naming the family and subcommand if we
|
|
894
|
+
# can identify one.
|
|
895
|
+
if cmd_base == "gh":
|
|
896
|
+
result = detect_gh_subcommand(segment)
|
|
897
|
+
if result is not None:
|
|
898
|
+
group, action = result
|
|
899
|
+
return (
|
|
900
|
+
"ask",
|
|
901
|
+
f"unknown gh subcommand: {group} {action}".rstrip()
|
|
902
|
+
+ " — not on the documented read-only allow-list; "
|
|
903
|
+
"ask before invoking",
|
|
904
|
+
)
|
|
905
|
+
return ("ask", "unknown gh invocation — ask before invoking")
|
|
906
|
+
|
|
907
|
+
if cmd_base == "git":
|
|
908
|
+
sub = detect_git_subcommand(segment)
|
|
909
|
+
if sub is not None:
|
|
910
|
+
return (
|
|
911
|
+
"ask",
|
|
912
|
+
f"unknown git subcommand: {sub} — not on the documented "
|
|
913
|
+
"read-only allow-list; ask before invoking",
|
|
914
|
+
)
|
|
915
|
+
return ("ask", "unknown git invocation — ask before invoking")
|
|
916
|
+
|
|
917
|
+
if cmd_base == "npm":
|
|
918
|
+
sub = detect_npm_subcommand(segment)
|
|
919
|
+
if sub is not None:
|
|
920
|
+
return (
|
|
921
|
+
"ask",
|
|
922
|
+
f"unknown npm subcommand: {sub} — not on the documented "
|
|
923
|
+
"read-only allow-list; ask before invoking",
|
|
924
|
+
)
|
|
925
|
+
return ("ask", "unknown npm invocation — ask before invoking")
|
|
926
|
+
|
|
927
|
+
return None
|
|
928
|
+
|
|
929
|
+
|
|
520
930
|
def _trusted_git_init_token_path(repo_root: Path) -> Path | None:
|
|
521
931
|
"""Return the trusted git-init allow-token path if the env signals it.
|
|
522
932
|
|
|
@@ -703,9 +1113,15 @@ def classify_rm_target(
|
|
|
703
1113
|
|
|
704
1114
|
|
|
705
1115
|
def classify_find_delete(segment: str) -> tuple[str, str] | None:
|
|
706
|
-
"""Check if segment is a find command with
|
|
1116
|
+
"""Check if segment is a find command with a mutating action flag.
|
|
707
1117
|
|
|
708
|
-
|
|
1118
|
+
Mutating action flags: -delete, -exec, -execdir, -fprint*, -ok.
|
|
1119
|
+
Returns classification tuple or None if find has no mutating action.
|
|
1120
|
+
|
|
1121
|
+
The allow-list (see classify_allow_list) admits `find` ONLY when none
|
|
1122
|
+
of these flags are present. This classifier returns "ask" for find
|
|
1123
|
+
invocations that DO carry a mutating action — the allow-list will
|
|
1124
|
+
not match them, and they fall through to this check.
|
|
709
1125
|
"""
|
|
710
1126
|
try:
|
|
711
1127
|
tokens = shlex.split(segment)
|
|
@@ -717,16 +1133,24 @@ def classify_find_delete(segment: str) -> tuple[str, str] | None:
|
|
|
717
1133
|
return None
|
|
718
1134
|
|
|
719
1135
|
has_delete = '-delete' in tokens
|
|
720
|
-
|
|
1136
|
+
has_exec = False
|
|
1137
|
+
has_execdir = False
|
|
1138
|
+
has_fprint = False
|
|
1139
|
+
has_ok = False
|
|
721
1140
|
for i, tok in enumerate(tokens):
|
|
722
|
-
if tok == '-exec'
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
1141
|
+
if tok == '-exec':
|
|
1142
|
+
has_exec = True
|
|
1143
|
+
elif tok == '-execdir':
|
|
1144
|
+
has_execdir = True
|
|
1145
|
+
elif tok in ('-ok', '-okdir'):
|
|
1146
|
+
has_ok = True
|
|
1147
|
+
elif tok in ('-fprint', '-fprint0', '-fprintf'):
|
|
1148
|
+
has_fprint = True
|
|
1149
|
+
|
|
1150
|
+
if not (has_delete or has_exec or has_execdir or has_fprint or has_ok):
|
|
727
1151
|
return None
|
|
728
1152
|
|
|
729
|
-
return "ask", f"find with
|
|
1153
|
+
return "ask", f"find with mutating action flag (-delete/-exec/-execdir/-fprint/-ok)"
|
|
730
1154
|
|
|
731
1155
|
|
|
732
1156
|
def extract_command_substitutions(raw: str) -> list[str]:
|
|
@@ -1030,6 +1454,24 @@ def classify_command(
|
|
|
1030
1454
|
if find_result:
|
|
1031
1455
|
escalate(*find_result)
|
|
1032
1456
|
|
|
1457
|
+
# --- DANGER-LATCH-CALIBRATION-001 ---
|
|
1458
|
+
# Hybrid fail-closed for governed families (git/gh/npm).
|
|
1459
|
+
# Run the allow-list AFTER all deny/confirm/rm/find checks have
|
|
1460
|
+
# had a chance to escalate. The allow-list itself never escalates
|
|
1461
|
+
# (allow is the lowest tier). It exists to:
|
|
1462
|
+
# (a) tell the governed-family default below to leave this
|
|
1463
|
+
# segment alone (do not escalate to ask),
|
|
1464
|
+
# (b) be auditable as an explicit admit decision in future
|
|
1465
|
+
# diagnostics.
|
|
1466
|
+
# If the segment is on the allow-list, skip the hybrid default
|
|
1467
|
+
# check. If it is NOT on the allow-list AND the segment is a
|
|
1468
|
+
# governed-family command, escalate to "ask".
|
|
1469
|
+
allow_result = classify_allow_list(segment)
|
|
1470
|
+
if allow_result is None:
|
|
1471
|
+
family_result = classify_governed_family_default(segment)
|
|
1472
|
+
if family_result is not None:
|
|
1473
|
+
escalate(*family_result)
|
|
1474
|
+
|
|
1033
1475
|
return worst_decision, worst_reason
|
|
1034
1476
|
|
|
1035
1477
|
|