@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.4",
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) — must match across | boundary
52
- (r"\b(curl|wget)\b.*\|\s*(ba)?sh\b", "pipe-to-shell execution"),
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 -delete or -exec rm.
1116
+ """Check if segment is a find command with a mutating action flag.
707
1117
 
708
- Returns classification tuple or None if not a find-delete.
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
- has_exec_rm = False
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' and i + 1 < len(tokens) and 'rm' in tokens[i + 1]:
723
- has_exec_rm = True
724
- break
725
-
726
- if not has_delete and not has_exec_rm:
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 delete action"
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