@jaguilar87/gaia 5.1.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +11 -0
  4. package/agents/gaia-orchestrator.md +4 -10
  5. package/bin/README.md +1 -1
  6. package/bin/cli/approvals.py +0 -144
  7. package/bin/cli/cleanup.py +1 -30
  8. package/bin/cli/context.py +379 -6
  9. package/bin/cli/doctor.py +1 -1
  10. package/bin/cli/history.py +18 -6
  11. package/bin/cli/install.py +4 -2
  12. package/bin/cli/memory.py +86 -10
  13. package/bin/cli/metrics.py +1216 -275
  14. package/bin/cli/query.py +89 -0
  15. package/bin/cli/scan.py +68 -2
  16. package/bin/cli/uninstall.py +9 -5
  17. package/bin/pre-publish-validate.js +7 -4
  18. package/gaia/project.py +49 -20
  19. package/gaia/store/reader.py +175 -11
  20. package/gaia/store/schema.sql +180 -1
  21. package/gaia/store/writer.py +1146 -165
  22. package/hooks/README.md +2 -2
  23. package/hooks/adapters/claude_code.py +6 -12
  24. package/hooks/elicitation_result.py +4 -9
  25. package/hooks/modules/agents/contract_validator.py +2 -1
  26. package/hooks/modules/agents/handoff_persister.py +5 -220
  27. package/hooks/modules/agents/task_info_builder.py +9 -2
  28. package/hooks/modules/agents/transcript_reader.py +41 -21
  29. package/hooks/modules/audit/logger.py +8 -3
  30. package/hooks/modules/context/context_injector.py +10 -3
  31. package/hooks/modules/core/__init__.py +5 -2
  32. package/hooks/modules/core/logging_setup.py +60 -0
  33. package/hooks/modules/core/paths.py +0 -12
  34. package/hooks/modules/security/approval_grants.py +24 -92
  35. package/hooks/modules/security/gaia_db_write_guard.py +7 -2
  36. package/hooks/modules/security/mutative_verbs.py +194 -11
  37. package/hooks/modules/session/pending_scanner.py +24 -222
  38. package/hooks/modules/session/session_manifest.py +13 -288
  39. package/hooks/post_compact.py +4 -9
  40. package/hooks/post_tool_use.py +4 -9
  41. package/hooks/pre_compact.py +5 -10
  42. package/hooks/pre_tool_use.py +4 -11
  43. package/hooks/session_end_hook.py +4 -9
  44. package/hooks/session_start.py +18 -21
  45. package/hooks/stop_hook.py +5 -10
  46. package/hooks/subagent_start.py +5 -10
  47. package/hooks/subagent_stop.py +9 -23
  48. package/hooks/task_completed.py +5 -10
  49. package/hooks/user_prompt_submit.py +4 -9
  50. package/package.json +1 -1
  51. package/pyproject.toml +1 -1
  52. package/scripts/migrations/schema.checksum +2 -2
  53. package/scripts/migrations/v21_to_v22.sql +80 -0
  54. package/scripts/migrations/v22_to_v23.sql +20 -0
  55. package/scripts/migrations/v23_to_v24.sql +30 -0
  56. package/scripts/migrations/v24_to_v25.sql +77 -0
  57. package/scripts/migrations/v25_to_v26.sql +116 -0
  58. package/skills/README.md +8 -0
  59. package/skills/agent-approval-protocol/SKILL.md +27 -27
  60. package/skills/agent-approval-protocol/reference.md +20 -12
  61. package/skills/agent-protocol/SKILL.md +1 -1
  62. package/skills/agent-protocol/examples.md +17 -9
  63. package/skills/agent-response/SKILL.md +1 -1
  64. package/skills/diagram-builder/GLOSSARY.md +77 -0
  65. package/skills/diagram-builder/SKILL.md +156 -0
  66. package/skills/diagram-builder/assets/README.md +45 -0
  67. package/skills/diagram-builder/assets/data/data.generated.js +77 -0
  68. package/skills/diagram-builder/assets/data/document.yaml +12 -0
  69. package/skills/diagram-builder/assets/data/pages/overview.yaml +47 -0
  70. package/skills/diagram-builder/assets/engine/build-data.mjs +51 -0
  71. package/skills/diagram-builder/assets/engine/engine.js +863 -0
  72. package/skills/diagram-builder/assets/index.html +638 -0
  73. package/skills/diagram-builder/assets/package.json +15 -0
  74. package/skills/diagram-builder/assets/tools/verify.mjs +128 -0
  75. package/skills/diagram-builder/reference.md +444 -0
  76. package/skills/execution/SKILL.md +3 -3
  77. package/skills/gaia-patterns/reference.md +2 -2
  78. package/skills/memory/SKILL.md +49 -0
  79. package/skills/orchestrator-present-approval/SKILL.md +90 -79
  80. package/skills/orchestrator-present-approval/reference.md +59 -42
  81. package/skills/orchestrator-present-approval/template.md +16 -14
  82. package/skills/pending-approvals/SKILL.md +80 -29
  83. package/skills/pending-approvals/reference.md +48 -60
  84. package/skills/security-tiers/SKILL.md +1 -1
  85. package/skills/subagent-request-approval/SKILL.md +65 -68
  86. package/skills/subagent-request-approval/reference.md +63 -63
  87. package/skills/visual-verify/SKILL.md +107 -0
  88. package/skills/visual-verify/scripts/screenshot.js +210 -0
  89. package/tools/scan/classify.py +584 -15
  90. package/tools/scan/migrate_workspace.py +9 -4
  91. package/tools/scan/store_populator.py +231 -2
  92. package/tools/scan/tests/conftest.py +31 -0
@@ -47,7 +47,7 @@ The current model is:
47
47
  pending approval via AskUserQuestion. It carries a semantic signature
48
48
  (base command + semantic tokens + normalized flags), is **session-agnostic**
49
49
  (see check_db_semantic_grant in gaia.store.writer), and lives for
50
- ``APPROVAL_GRANT_TTL_MINUTES`` (60 minutes, the value reflected by
50
+ ``APPROVAL_GRANT_TTL_MINUTES`` (5 minutes, the value reflected by
51
51
  DEFAULT_GRANT_TTL_MINUTES above).
52
52
 
53
53
  2. The grant is **consumed on the matching retry**, NOT at SubagentStop and
@@ -71,9 +71,9 @@ Each command in the set is approved explicitly by the user and consumed
71
71
  individually. The legacy verb_family path has been removed.
72
72
 
73
73
  NOTE: the filesystem helpers below (write_pending_approval, the
74
- grant-{session}-*.json scanners, consume_session_grants) are the DEPRECATED
75
- fallback plane retained for grants created before the DB cutover. The active
76
- flow runs through the DB plane in gaia.store.writer.
74
+ grant-{session}-*.json scanners) are the DEPRECATED fallback plane retained for
75
+ grants created before the DB cutover. The active flow runs through the DB plane
76
+ in gaia.store.writer.
77
77
  """
78
78
 
79
79
  from __future__ import annotations
@@ -115,7 +115,7 @@ def _grant_ttl_minutes() -> int:
115
115
  without a circular import (writer never imports this module back). We resolve
116
116
  it lazily here, mirroring every other gaia.store import in this file, because
117
117
  the hooks package can be imported before the `gaia` package is on sys.path;
118
- a module-level import would crash hook load in that window. The 60-minute
118
+ a module-level import would crash hook load in that window. The 5-minute
119
119
  fallback equals the canonical value, so the two never disagree even if the
120
120
  lazy import is briefly unavailable.
121
121
  """
@@ -123,12 +123,13 @@ def _grant_ttl_minutes() -> int:
123
123
  from gaia.store.writer import APPROVAL_GRANT_TTL_MINUTES as _ttl
124
124
  return _ttl
125
125
  except Exception:
126
- return 60
126
+ return 5
127
127
 
128
128
 
129
- # Default GRANT TTL in minutes -- the active-grant retry window. Moved 5 -> 60
130
- # (Change 3a) so a cross-session human-in-the-loop approval does not expire
131
- # before it is consumed; sourced from APPROVAL_GRANT_TTL_MINUTES in writer.
129
+ # Default GRANT TTL in minutes -- the active-grant retry window (approvals
130
+ # redesign, M1). The grant is consumed AT THE MATCH, so a short 5-minute window
131
+ # is enough to cover the block -> approve -> retry round trip; sourced from
132
+ # APPROVAL_GRANT_TTL_MINUTES in writer (the single point of truth).
132
133
  DEFAULT_GRANT_TTL_MINUTES = _grant_ttl_minutes()
133
134
 
134
135
  # Default PENDING TTL in minutes (24 hours). DELIBERATELY distinct from the grant
@@ -556,76 +557,6 @@ def consume_grant(command: str, session_id: str = None) -> bool:
556
557
  return False
557
558
 
558
559
 
559
- def consume_session_grants(session_id: str = None) -> int:
560
- """Proactively consume confirmed DB grants for a session at session end.
561
-
562
- Called at SubagentStop as a defense-in-depth measure. Since G3 cutover
563
- the authoritative grant plane is the DB only; this function sweeps all
564
- PENDING approval_grants rows whose confirmed=1 and marks them CONSUMED
565
- so unused grants cannot be replayed after the session closes.
566
-
567
- The primary replay guard remains consume-on-retry (bash_validator calls
568
- consume_db_semantic_grant after each allowed command) -- this function
569
- is a secondary sweep that catches any confirmed but unused DB grants that
570
- were never retried in the session.
571
-
572
- Args:
573
- session_id: Session ID to scope consumption (defaults to env var).
574
-
575
- Returns:
576
- Number of DB grants consumed (0 when none found or on error).
577
- """
578
- if not session_id:
579
- session_id = _get_session_id()
580
-
581
- consumed_count = 0
582
- try:
583
- from gaia.store.writer import consume_db_semantic_grant
584
- import gaia.store.writer as _sw
585
-
586
- con = _sw._connect()
587
- try:
588
- # Find all PENDING, confirmed semantic grants (session-scoped
589
- # for this sweep -- we only clean up what this session created).
590
- cur = con.execute(
591
- """
592
- SELECT approval_id
593
- FROM approval_grants
594
- WHERE scope = 'SCOPE_SEMANTIC_SIGNATURE'
595
- AND status = 'PENDING'
596
- AND confirmed = 1
597
- AND (session_id = ? OR session_id IS NULL)
598
- """,
599
- (session_id,),
600
- )
601
- rows = cur.fetchall()
602
- finally:
603
- con.close()
604
-
605
- for row in rows:
606
- approval_id = row[0] if isinstance(row, tuple) else row.get("approval_id")
607
- if not approval_id:
608
- continue
609
- try:
610
- consumed = consume_db_semantic_grant(approval_id)
611
- if consumed:
612
- consumed_count += 1
613
- logger.info(
614
- "Grant consumed at SubagentStop (DB): approval_id=%s",
615
- approval_id[:16],
616
- )
617
- except Exception as _ce:
618
- logger.debug(
619
- "consume_session_grants: DB consume failed for %s (non-fatal): %s",
620
- approval_id[:16], _ce,
621
- )
622
-
623
- except Exception as e:
624
- logger.error("Error consuming session grants (DB): %s", e)
625
-
626
- return consumed_count
627
-
628
-
629
560
  def confirm_grant(command: str, session_id: str = None) -> bool:
630
561
  """Set confirmed=1 on the first PENDING DB grant matching command.
631
562
 
@@ -873,11 +804,11 @@ def write_pending_approval_for_file(
873
804
  DB-primary since Task E of the approval redesign: persists to
874
805
  gaia.approvals.store (gaia.db) first using insert_requested() with
875
806
  approval_id = "P-" + nonce. The filesystem write is removed entirely --
876
- scan_pending_db() surfaces file-path pendings exactly like T3 command
877
- pendings now that the DB carries them.
807
+ file-path pendings live in the DB exactly like T3 command pendings and are
808
+ read on demand via `gaia approvals`.
878
809
 
879
810
  The sealed_payload uses:
880
- - exact_content = file_path (surfaced as "command" by scan_pending_db)
811
+ - exact_content = file_path (the blocked file path)
881
812
  - operation = "FILE_WRITE command intercepted: write"
882
813
  - scope = SCOPE_FILE_PATH constant
883
814
  - scope_signature = serialised ApprovalSignature for check/activation
@@ -1014,9 +945,9 @@ def find_pending_for_file(
1014
945
  if not stripped:
1015
946
  return None
1016
947
 
1017
- # DB path: query all pending rows (all_sessions=True -- see scan_pending_db
1018
- # for the rationale: the host session id inside a subagent is the subagent's
1019
- # id, not the orchestrator's, so session-scoping would silently miss the row).
948
+ # DB path: query all pending rows (all_sessions=True). The host session id
949
+ # inside a subagent is the subagent's id, not the orchestrator's, so
950
+ # session-scoping would silently miss the row.
1020
951
  try:
1021
952
  from gaia.approvals.store import list_pending
1022
953
  rows = list_pending(all_sessions=True)
@@ -1291,7 +1222,8 @@ def activate_db_pending_by_prefix(
1291
1222
  # consume side is unchanged; this is the create side that was orphaned.
1292
1223
  #
1293
1224
  # Precondition: ``command_set`` in the payload is already pre-filtered to
1294
- # mutative commands by ``_intake_command_set_pending`` (handoff_persister,
1225
+ # mutative commands by the compound-command intake in bash_validator
1226
+ # (``_validate_compound_command`` -> ``decide_t3_outcome(command_set=...)``,
1295
1227
  # the only producer of these pending records in production). Activation
1296
1228
  # therefore assumes every item is consumable and does NOT re-filter here;
1297
1229
  # do not add a filtering step at this site -- it would silently drop items
@@ -1532,12 +1464,12 @@ def activate_db_pending_by_prefix(
1532
1464
  # string and requires fresh approval. Each item in the set is single-use.
1533
1465
 
1534
1466
  # COMMAND_SET grant TTL in minutes. Aligned to the singular active-grant TTL
1535
- # (DEFAULT_GRANT_TTL_MINUTES / APPROVAL_GRANT_TTL_MINUTES = 60) so a batch of
1536
- # commands approved under one consent gets the same cross-session retry window
1537
- # as a single approved command -- the block-approve-retry flow legitimately
1538
- # spans sessions, and a shorter window would expire the batch before the
1539
- # subagent could consume every item.
1540
- DEFAULT_COMMAND_SET_TTL_MINUTES = 60
1467
+ # (DEFAULT_GRANT_TTL_MINUTES / APPROVAL_GRANT_TTL_MINUTES = 5) so a batch of
1468
+ # commands approved under one consent gets the same short retry window as a
1469
+ # single approved command -- the block-approve-retry flow is same-session and
1470
+ # single-use, so 5 minutes is enough to consume every item while keeping the
1471
+ # grant's live window tight.
1472
+ DEFAULT_COMMAND_SET_TTL_MINUTES = 5
1541
1473
 
1542
1474
 
1543
1475
  def create_command_set_grant(
@@ -8,8 +8,13 @@ Pattern detected:
8
8
  sqlite3\\s+.*(gaia\\.db|~/\\.gaia/.*\\.db).*(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|REPLACE)
9
9
  case-insensitive, inside quotes / heredocs.
10
10
 
11
- Read-only SQL (SELECT) is allowed. Bypass is possible via T3 approval grant
12
- for legitimate cases (manual migrations, debug ops).
11
+ Read-only SQL (SELECT) is allowed. The write block is categorical and NOT
12
+ approvable -- like blocked_commands.py, it denies with SecurityTier.T3_BLOCKED
13
+ and no approval_id, so there is no T3 grant that lifts it. Legitimate cases
14
+ go through the sanctioned paths instead:
15
+ - DDL / migrations: scripts/bootstrap_database.sh and tools/migration/*
16
+ - data writes: the `gaia context` CLI / update_contracts, backed by the
17
+ typed API in gaia/store/writer.py (which enforces _is_authorized())
13
18
 
14
19
  Public API:
15
20
  is_db_write_attempt(command: str) -> bool
@@ -408,21 +408,35 @@ _SCRIPT_FILE_INTERPRETERS: FrozenSet[str] = frozenset({
408
408
  "node", "ruby", "perl", "php",
409
409
  })
410
410
 
411
+ # Non-shell, non-Python interpreters: their script files are SOURCE CODE in a
412
+ # programming language, not shell command lists. Their content lane is "code"
413
+ # rather than "shell" so the regex classifier suppresses camelCase subcommand
414
+ # splitting -- a camelCase token in JS/Ruby/etc. (``execPath``, ``setState``)
415
+ # is a language identifier, not a CLI subcommand. Suppressing it removes only
416
+ # false positives: whole-token verbs and command aliases (``rm``/``cp`` used
417
+ # bare), dangerous flags, and blocked-command patterns are still scanned, and
418
+ # camelCase multi-word tokens embedded inside a quoted string argument were
419
+ # never matched anyway (the quote makes the whole string a single token).
420
+ _NON_SHELL_SCRIPT_INTERPRETERS: FrozenSet[str] = frozenset({
421
+ "node", "ruby", "perl", "php",
422
+ })
423
+
411
424
  # File extensions whose interpreter is implied by ``./script`` (no explicit
412
425
  # interpreter token). Maps the extension to the analysis lane used for its
413
426
  # content: "python" routes through the AST analyzer, "shell" through the
414
- # blocked/mutative regex layer.
427
+ # blocked/mutative regex layer, "code" through the same regex layer but with
428
+ # camelCase subcommand splitting suppressed (non-shell source languages).
415
429
  _SHEBANG_EXT_LANES: Dict[str, str] = {
416
430
  ".py": "python",
417
431
  ".sh": "shell",
418
432
  ".bash": "shell",
419
433
  ".zsh": "shell",
420
- ".js": "shell",
421
- ".mjs": "shell",
422
- ".cjs": "shell",
423
- ".rb": "shell",
424
- ".pl": "shell",
425
- ".php": "shell",
434
+ ".js": "code",
435
+ ".mjs": "code",
436
+ ".cjs": "code",
437
+ ".rb": "code",
438
+ ".pl": "code",
439
+ ".php": "code",
426
440
  }
427
441
 
428
442
  # Cap on bytes read from a script file during classification. A script larger
@@ -563,6 +577,101 @@ _UNIVERSAL_DANGEROUS_PATTERNS: Tuple[Tuple[_re.Pattern, str, str], ...] = (
563
577
  (_re.compile(r"\bfs\.chmod(Sync)?\s*\("), "fs-chmod", "PERMISSION_MOD"),
564
578
  )
565
579
 
580
+ # ---------------------------------------------------------------------------
581
+ # Exec-sink string-argument extraction (SHARED: inline code path + script-file
582
+ # code lane)
583
+ # ---------------------------------------------------------------------------
584
+ # ``_scan_exec_sink_string_args`` is the single detector both the inline
585
+ # ``-c``/``-e`` path (``_check_inline_code``) and the script-file "code" lane
586
+ # (``_classify_script_content_by_regex``) call, so exec-sink detection cannot
587
+ # diverge between them.
588
+ #
589
+ # The problem it closes: a command handed to a subprocess sink as a STRING
590
+ # LITERAL -- ``execSync("kubectl delete deployment foo")`` -- is invisible to
591
+ # the verb scanner because the quotes make the whole command a single token.
592
+ # The inline path caught the sink CALL via ``_UNIVERSAL_DANGEROUS_PATTERNS``,
593
+ # but the script-file lane never ran those patterns, so ``node deploy.js`` with
594
+ # an ``execSync(...)`` mutation slipped through as READ_ONLY.
595
+ #
596
+ # False-positive mitigation (required): this is the PROCESS_EXECUTION subset of
597
+ # the universal patterns -- ONLY the call forms that take a command string --
598
+ # NOT the full pattern set (which would flag every legitimate fs.writeFile /
599
+ # fetch / URL literal in a real source file). Escalation is gated on the
600
+ # EXTRACTED INNER command itself classifying mutative/blocked, so a benign
601
+ # ``execSync("ls")`` is not escalated.
602
+ #
603
+ # ``exec``/``system`` are intentionally generic (they match ruby/perl/php
604
+ # ``system(...)`` and node ``exec(...)``); the inner-command gate keeps the
605
+ # false-positive cost near zero -- ``regex.exec("literal")`` extracts
606
+ # ``literal``, which is not a mutative command, so nothing escalates.
607
+ _EXEC_SINK_STRING_ARG_RE = _re.compile(
608
+ r"\b(?:execSync|execFileSync|execFile|spawnSync|spawn|shell_exec|passthru|"
609
+ r"proc_open|system|popen|Popen|exec)\s*\(\s*"
610
+ r"(?P<q>['\"])(?P<cmd>(?:[^'\"\\]|\\.)*)(?P=q)"
611
+ )
612
+ # Backtick / ``%x{...}`` shell execution (ruby / perl / php): the body IS the
613
+ # command handed to the shell.
614
+ _EXEC_SINK_BACKTICK_RE = _re.compile(r"`([^`\n]{2,})`")
615
+ _EXEC_SINK_PERCENT_X_RE = _re.compile(r"%x[\{\(\[]([^\}\)\]\n]{2,})[\}\)\]]")
616
+
617
+
618
+ def _scan_exec_sink_string_args(code: str, family: str) -> "Optional[MutativeResult]":
619
+ """Extract shell commands handed to exec sinks and re-classify them.
620
+
621
+ Shared by ``_check_inline_code`` (inline ``-c``/``-e`` payloads) and
622
+ ``_classify_script_content_by_regex`` (``node deploy.js`` script files) so
623
+ exec-sink detection cannot diverge between the two lanes.
624
+
625
+ For each exec-sink call whose first argument is a string literal
626
+ (``execSync("...")``, ``system("...")``, ``spawn("...")``, ...) and for each
627
+ backtick / ``%x{}`` shell body, the extracted command is re-classified with
628
+ ``is_blocked_command`` and ``detect_mutative_command``. A MUTATIVE result
629
+ is returned ONLY when the inner command is itself blocked or mutative -- a
630
+ benign inner command (``execSync("ls")``) yields ``None`` (false-positive
631
+ mitigation).
632
+ """
633
+ candidates: List[str] = []
634
+ for m in _EXEC_SINK_STRING_ARG_RE.finditer(code):
635
+ candidates.append(m.group("cmd"))
636
+ for m in _EXEC_SINK_BACKTICK_RE.finditer(code):
637
+ candidates.append(m.group(1))
638
+ for m in _EXEC_SINK_PERCENT_X_RE.finditer(code):
639
+ candidates.append(m.group(1))
640
+
641
+ for raw_inner in candidates:
642
+ inner = raw_inner.strip()
643
+ if not inner:
644
+ continue
645
+ if _is_blocked_command is not None:
646
+ blocked = _is_blocked_command(inner)
647
+ if blocked.is_blocked:
648
+ return MutativeResult(
649
+ is_mutative=True,
650
+ category=CATEGORY_MUTATIVE,
651
+ verb="exec-sink-blocked-cmd",
652
+ cli_family=family,
653
+ confidence="high",
654
+ reason=(
655
+ f"exec-sink argument is a blocked command: "
656
+ f"{blocked.category}"
657
+ ),
658
+ )
659
+ inner_result = detect_mutative_command(inner)
660
+ if inner_result.is_mutative:
661
+ return MutativeResult(
662
+ is_mutative=True,
663
+ category=CATEGORY_MUTATIVE,
664
+ verb=inner_result.verb,
665
+ dangerous_flags=inner_result.dangerous_flags,
666
+ cli_family=family,
667
+ confidence=inner_result.confidence,
668
+ reason=(
669
+ f"exec-sink argument '{inner}' -> {inner_result.reason}"
670
+ ),
671
+ )
672
+ return None
673
+
674
+
566
675
  # ---------------------------------------------------------------------------
567
676
  # Layer 3: Heuristic safety classification
568
677
  # ---------------------------------------------------------------------------
@@ -1043,7 +1152,9 @@ def _is_subcommand_identifier(token: str) -> bool:
1043
1152
  # ============================================================================
1044
1153
 
1045
1154
  @functools.lru_cache(maxsize=128)
1046
- def detect_mutative_command(command: str) -> MutativeResult:
1155
+ def detect_mutative_command(
1156
+ command: str, from_source_code: bool = False,
1157
+ ) -> MutativeResult:
1047
1158
  """Analyze a shell command and return a structured mutative assessment.
1048
1159
 
1049
1160
  Simplified algorithm (CLI-agnostic):
@@ -1056,6 +1167,15 @@ def detect_mutative_command(command: str) -> MutativeResult:
1056
1167
 
1057
1168
  Args:
1058
1169
  command: Raw shell command string.
1170
+ from_source_code: True when ``command`` is a single line drawn from a
1171
+ non-shell SOURCE file (``.js``/``.ts``/``.rb``/...) being scanned
1172
+ by the script-content classifier, rather than a shell command line
1173
+ or a line from a shell script. When True, camelCase subcommand
1174
+ splitting is suppressed: a camelCase token in source is a language
1175
+ identifier (``execPath``, ``setState``), not a CLI subcommand, and
1176
+ splitting it produced spurious mutative matches. Whole-token and
1177
+ hyphen matching are unaffected. Defaults to False so shell command
1178
+ lines and shell scripts keep the full camelCase behavior.
1059
1179
 
1060
1180
  Returns:
1061
1181
  MutativeResult with full classification details.
@@ -1577,12 +1697,27 @@ def detect_mutative_command(command: str) -> MutativeResult:
1577
1697
  # semantic_index == 1. Without this guard, ``pytest
1578
1698
  # tests/test_install.py::TestStop`` splits ``TestStop`` and matches
1579
1699
  # ``stop``, misclassifying the whole command as MUTATIVE.
1700
+ #
1701
+ # Source-code guard (word-boundary discipline for non-shell script
1702
+ # content): camelCase multi-word tokens are split so a CLI subcommand
1703
+ # like ``batchDelete`` -> ``delete`` is caught. But when the scanned
1704
+ # content is a non-shell SOURCE language (``.js``/``.ts``/``.rb`` etc.,
1705
+ # see ``from_source_code``), a camelCase token is overwhelmingly a
1706
+ # source identifier, not a CLI subcommand -- ``execPath``/``execSync``
1707
+ # -> ``exec``, ``setState`` -> ``set``, ``stopPropagation`` -> ``stop``,
1708
+ # ``postMessage`` -> ``post``, ``createElement`` -> ``create``.
1709
+ # Splitting those forced spurious T3 on read-only scripts. The guard
1710
+ # fires ONLY for source-code content, so shell command lines and shell
1711
+ # scripts (``aws batchDelete``, ``mytool batchDelete``) are unchanged,
1712
+ # and whole-token / hyphen matching (``mytool install``, ``git tag``)
1713
+ # is never gated.
1580
1714
  raw_token = semantics.semantic_head_tokens_raw[semantic_index] if semantic_index < len(semantics.semantic_head_tokens_raw) else token
1581
1715
  camel_parts = split_camel_case(raw_token)
1582
1716
  if (
1583
1717
  semantic_index == 1
1584
1718
  and len(camel_parts) > 1
1585
1719
  and _is_subcommand_identifier(raw_token)
1720
+ and not from_source_code
1586
1721
  ):
1587
1722
  for part in camel_parts:
1588
1723
  if part in MUTATIVE_VERBS:
@@ -1826,7 +1961,12 @@ def _resolve_script_argument(
1826
1961
  return None
1827
1962
 
1828
1963
  if base_cmd in _SCRIPT_FILE_INTERPRETERS:
1829
- lane = "python" if base_cmd in _PYTHON_INTERPRETERS else "shell"
1964
+ if base_cmd in _PYTHON_INTERPRETERS:
1965
+ lane = "python"
1966
+ elif base_cmd in _NON_SHELL_SCRIPT_INTERPRETERS:
1967
+ lane = "code"
1968
+ else:
1969
+ lane = "shell"
1830
1970
  defer_flags = _INTERP_NON_SCRIPT_VALUE_FLAGS.get(base_cmd, frozenset())
1831
1971
  # Walk the args (original casing) and return the first true positional
1832
1972
  # -- the script file. Standalone interpreter switches (-u, -O, -x, ...)
@@ -1966,11 +2106,17 @@ def _check_script_file(
1966
2106
  )
1967
2107
  # parse_failed -> fall through to the shell/regex lane below.
1968
2108
 
1969
- return _classify_script_content_by_regex(content, script_path, family)
2109
+ # "code" lane (non-shell source: node/ruby/perl/php) suppresses camelCase
2110
+ # subcommand splitting so language identifiers are not read as CLI verbs;
2111
+ # "shell" lane keeps the full command semantics.
2112
+ return _classify_script_content_by_regex(
2113
+ content, script_path, family, from_source_code=(lane == "code"),
2114
+ )
1970
2115
 
1971
2116
 
1972
2117
  def _classify_script_content_by_regex(
1973
2118
  content: str, script_path: str, family: str,
2119
+ from_source_code: bool = False,
1974
2120
  ) -> MutativeResult:
1975
2121
  """Classify shell / non-Python script content via the existing regex layer.
1976
2122
 
@@ -1984,6 +2130,11 @@ def _classify_script_content_by_regex(
1984
2130
  per logical line so a ``kubectl apply`` or ``curl -X POST`` inside the
1985
2131
  file is detected the same way it would be on the command line.
1986
2132
 
2133
+ ``from_source_code`` is forwarded to ``detect_mutative_command`` so that
2134
+ non-shell source content (``.js``/``.rb``/...) suppresses camelCase
2135
+ subcommand splitting -- a source identifier like ``execPath`` must not be
2136
+ read as the CLI verb ``exec``. Shell-script content leaves it False.
2137
+
1987
2138
  This reuses the existing layers rather than introducing a new parser, per
1988
2139
  the design constraint.
1989
2140
  """
@@ -2007,7 +2158,7 @@ def _classify_script_content_by_regex(
2007
2158
  ),
2008
2159
  )
2009
2160
 
2010
- line_result = detect_mutative_command(line)
2161
+ line_result = detect_mutative_command(line, from_source_code=from_source_code)
2011
2162
  if line_result.is_mutative:
2012
2163
  return MutativeResult(
2013
2164
  is_mutative=True,
@@ -2022,6 +2173,28 @@ def _classify_script_content_by_regex(
2022
2173
  ),
2023
2174
  )
2024
2175
 
2176
+ # Exec-sink string-argument re-classification (shared with the inline
2177
+ # code path via ``_scan_exec_sink_string_args``). A source line like
2178
+ # ``execSync("kubectl delete ...")`` hides a mutative command inside a
2179
+ # string literal the verb scanner cannot see (the quotes make it one
2180
+ # token). Extract the command handed to a known exec sink and
2181
+ # re-classify it; escalate ONLY when the inner command is itself
2182
+ # mutative/blocked, so a benign ``execSync("ls")`` is not escalated.
2183
+ sink_result = _scan_exec_sink_string_args(line, family)
2184
+ if sink_result is not None and sink_result.is_mutative:
2185
+ return MutativeResult(
2186
+ is_mutative=True,
2187
+ category=sink_result.category,
2188
+ verb=sink_result.verb,
2189
+ dangerous_flags=sink_result.dangerous_flags,
2190
+ cli_family=family,
2191
+ confidence=sink_result.confidence,
2192
+ reason=(
2193
+ f"Script '{script_path}' exec-sink invokes mutative "
2194
+ f"command: {sink_result.reason}"
2195
+ ),
2196
+ )
2197
+
2025
2198
  return MutativeResult(
2026
2199
  is_mutative=False,
2027
2200
  category=CATEGORY_READ_ONLY,
@@ -2240,6 +2413,16 @@ def _check_inline_code(command: str, base_cmd: str, family: str, skip_length_che
2240
2413
  )
2241
2414
  # parse_failed=True -> fall through to regex layer 2b below.
2242
2415
 
2416
+ # ---- Layer 2a: Exec-sink string-argument re-classification ----
2417
+ # Shared with the script-file code lane (``_classify_script_content_by_regex``)
2418
+ # so the two lanes cannot diverge on how a command handed to a subprocess
2419
+ # sink is detected. Runs before the broader Layer 2b keyword scan. For a
2420
+ # payload that already parsed cleanly as Python this point is unreachable
2421
+ # (the AST block returned above), so Python behavior is unchanged.
2422
+ sink_result = _scan_exec_sink_string_args(command, family)
2423
+ if sink_result is not None and sink_result.is_mutative:
2424
+ return sink_result
2425
+
2243
2426
  # ---- Layer 2b: Universal dangerous API keyword patterns ----
2244
2427
  for pattern, label, category in _UNIVERSAL_DANGEROUS_PATTERNS:
2245
2428
  if pattern.search(command):