@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/agents/gaia-orchestrator.md +4 -10
- package/bin/README.md +1 -1
- package/bin/cli/approvals.py +0 -144
- package/bin/cli/cleanup.py +1 -30
- package/bin/cli/context.py +379 -6
- package/bin/cli/doctor.py +1 -1
- package/bin/cli/history.py +18 -6
- package/bin/cli/install.py +4 -2
- package/bin/cli/memory.py +86 -10
- package/bin/cli/metrics.py +1216 -275
- package/bin/cli/query.py +89 -0
- package/bin/cli/scan.py +68 -2
- package/bin/cli/uninstall.py +9 -5
- package/bin/pre-publish-validate.js +7 -4
- package/gaia/project.py +49 -20
- package/gaia/store/reader.py +175 -11
- package/gaia/store/schema.sql +180 -1
- package/gaia/store/writer.py +1146 -165
- package/hooks/README.md +2 -2
- package/hooks/adapters/claude_code.py +6 -12
- package/hooks/elicitation_result.py +4 -9
- package/hooks/modules/agents/contract_validator.py +2 -1
- package/hooks/modules/agents/handoff_persister.py +5 -220
- package/hooks/modules/agents/task_info_builder.py +9 -2
- package/hooks/modules/agents/transcript_reader.py +41 -21
- package/hooks/modules/audit/logger.py +8 -3
- package/hooks/modules/context/context_injector.py +10 -3
- package/hooks/modules/core/__init__.py +5 -2
- package/hooks/modules/core/logging_setup.py +60 -0
- package/hooks/modules/core/paths.py +0 -12
- package/hooks/modules/security/approval_grants.py +24 -92
- package/hooks/modules/security/gaia_db_write_guard.py +7 -2
- package/hooks/modules/security/mutative_verbs.py +194 -11
- package/hooks/modules/session/pending_scanner.py +24 -222
- package/hooks/modules/session/session_manifest.py +13 -288
- package/hooks/post_compact.py +4 -9
- package/hooks/post_tool_use.py +4 -9
- package/hooks/pre_compact.py +5 -10
- package/hooks/pre_tool_use.py +4 -11
- package/hooks/session_end_hook.py +4 -9
- package/hooks/session_start.py +18 -21
- package/hooks/stop_hook.py +5 -10
- package/hooks/subagent_start.py +5 -10
- package/hooks/subagent_stop.py +9 -23
- package/hooks/task_completed.py +5 -10
- package/hooks/user_prompt_submit.py +4 -9
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/migrations/schema.checksum +2 -2
- package/scripts/migrations/v21_to_v22.sql +80 -0
- package/scripts/migrations/v22_to_v23.sql +20 -0
- package/scripts/migrations/v23_to_v24.sql +30 -0
- package/scripts/migrations/v24_to_v25.sql +77 -0
- package/scripts/migrations/v25_to_v26.sql +116 -0
- package/skills/README.md +8 -0
- package/skills/agent-approval-protocol/SKILL.md +27 -27
- package/skills/agent-approval-protocol/reference.md +20 -12
- package/skills/agent-protocol/SKILL.md +1 -1
- package/skills/agent-protocol/examples.md +17 -9
- package/skills/agent-response/SKILL.md +1 -1
- package/skills/diagram-builder/GLOSSARY.md +77 -0
- package/skills/diagram-builder/SKILL.md +156 -0
- package/skills/diagram-builder/assets/README.md +45 -0
- package/skills/diagram-builder/assets/data/data.generated.js +77 -0
- package/skills/diagram-builder/assets/data/document.yaml +12 -0
- package/skills/diagram-builder/assets/data/pages/overview.yaml +47 -0
- package/skills/diagram-builder/assets/engine/build-data.mjs +51 -0
- package/skills/diagram-builder/assets/engine/engine.js +863 -0
- package/skills/diagram-builder/assets/index.html +638 -0
- package/skills/diagram-builder/assets/package.json +15 -0
- package/skills/diagram-builder/assets/tools/verify.mjs +128 -0
- package/skills/diagram-builder/reference.md +444 -0
- package/skills/execution/SKILL.md +3 -3
- package/skills/gaia-patterns/reference.md +2 -2
- package/skills/memory/SKILL.md +49 -0
- package/skills/orchestrator-present-approval/SKILL.md +90 -79
- package/skills/orchestrator-present-approval/reference.md +59 -42
- package/skills/orchestrator-present-approval/template.md +16 -14
- package/skills/pending-approvals/SKILL.md +80 -29
- package/skills/pending-approvals/reference.md +48 -60
- package/skills/security-tiers/SKILL.md +1 -1
- package/skills/subagent-request-approval/SKILL.md +65 -68
- package/skills/subagent-request-approval/reference.md +63 -63
- package/skills/visual-verify/SKILL.md +107 -0
- package/skills/visual-verify/scripts/screenshot.js +210 -0
- package/tools/scan/classify.py +584 -15
- package/tools/scan/migrate_workspace.py +9 -4
- package/tools/scan/store_populator.py +231 -2
- 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`` (
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
|
126
|
+
return 5
|
|
127
127
|
|
|
128
128
|
|
|
129
|
-
# Default GRANT TTL in minutes -- the active-grant retry window
|
|
130
|
-
#
|
|
131
|
-
#
|
|
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
|
-
|
|
877
|
-
|
|
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 (
|
|
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
|
|
1018
|
-
#
|
|
1019
|
-
#
|
|
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
|
|
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 =
|
|
1536
|
-
# commands approved under one consent gets the same
|
|
1537
|
-
#
|
|
1538
|
-
#
|
|
1539
|
-
#
|
|
1540
|
-
DEFAULT_COMMAND_SET_TTL_MINUTES =
|
|
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.
|
|
12
|
-
|
|
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": "
|
|
421
|
-
".mjs": "
|
|
422
|
-
".cjs": "
|
|
423
|
-
".rb": "
|
|
424
|
-
".pl": "
|
|
425
|
-
".php": "
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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):
|