@jaguilar87/gaia 5.0.9 → 5.0.10
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 +2 -0
- package/bin/README.md +4 -2
- package/bin/cli/_install_helpers.py +0 -3
- package/bin/cli/brief.py +32 -4
- package/bin/cli/cleanup.py +304 -4
- package/bin/cli/doctor.py +0 -4
- package/bin/cli/uninstall.py +20 -0
- package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +0 -5
- package/dist/gaia-ops/hooks/modules/security/capability_classes.py +83 -6
- package/dist/gaia-ops/hooks/modules/security/inline_ast_analyzer.py +237 -0
- package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +410 -0
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +177 -20
- package/dist/gaia-ops/skills/security-tiers/SKILL.md +1 -1
- package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-security/hooks/modules/core/plugin_setup.py +0 -5
- package/dist/gaia-security/hooks/modules/security/capability_classes.py +83 -6
- package/dist/gaia-security/hooks/modules/security/inline_ast_analyzer.py +237 -0
- package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +410 -0
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +177 -20
- package/gaia/briefs/__init__.py +4 -0
- package/gaia/briefs/store.py +91 -0
- package/hooks/modules/core/plugin_setup.py +0 -5
- package/hooks/modules/security/capability_classes.py +83 -6
- package/hooks/modules/security/inline_ast_analyzer.py +237 -0
- package/hooks/modules/security/mutative_verbs.py +410 -0
- package/hooks/modules/tools/bash_validator.py +177 -20
- package/package.json +1 -1
- package/pyproject.toml +20 -1
- package/skills/security-tiers/SKILL.md +1 -1
|
@@ -911,6 +911,57 @@ class BashValidator:
|
|
|
911
911
|
reason="Safe by elimination (not blocked, not mutative)",
|
|
912
912
|
)
|
|
913
913
|
|
|
914
|
+
def _is_ungranted_t3_component(
|
|
915
|
+
self, component: str, session_id: str
|
|
916
|
+
) -> bool:
|
|
917
|
+
"""Classify a chain component as ungranted-T3 WITHOUT minting or consuming.
|
|
918
|
+
|
|
919
|
+
Returns True when the component is a T3 (mutative-verb or
|
|
920
|
+
flag-dependent) operation for which NO active grant exists -- i.e. the
|
|
921
|
+
component would, on its own, be blocked pending approval. This is a
|
|
922
|
+
read-only probe used by the chain COMMAND_SET intake (AC-8) to decide
|
|
923
|
+
whether >= 2 sub-commands need grouping under ONE consent, BEFORE any
|
|
924
|
+
per-component minting happens.
|
|
925
|
+
|
|
926
|
+
It deliberately does NOT call decide_t3_outcome (no pending minted) and
|
|
927
|
+
does NOT consume any grant (match_command_set_grant /
|
|
928
|
+
check_approval_grant are pure lookups; consumption happens later in the
|
|
929
|
+
real _validate_single_command pass at retry). A component that already
|
|
930
|
+
matches a COMMAND_SET or semantic grant is treated as NOT ungranted, so
|
|
931
|
+
it is excluded from a fresh batch.
|
|
932
|
+
"""
|
|
933
|
+
component = component.strip()
|
|
934
|
+
if not component:
|
|
935
|
+
return False
|
|
936
|
+
|
|
937
|
+
# Is this T3 (mutative verb or flag-dependent mutation)?
|
|
938
|
+
detect = detect_mutative_command(component)
|
|
939
|
+
is_t3 = detect.is_mutative
|
|
940
|
+
if not is_t3:
|
|
941
|
+
flag_result = classify_by_flags(component)
|
|
942
|
+
if (
|
|
943
|
+
flag_result is not None
|
|
944
|
+
and flag_result.outcome == FLAG_MUTATIVE
|
|
945
|
+
and not flag_result.command_family.startswith("git_")
|
|
946
|
+
):
|
|
947
|
+
is_t3 = True
|
|
948
|
+
if not is_t3:
|
|
949
|
+
return False
|
|
950
|
+
|
|
951
|
+
# Already covered by an active grant? Then it is NOT ungranted -- exclude
|
|
952
|
+
# it from a fresh batch (pure lookups, no consumption).
|
|
953
|
+
try:
|
|
954
|
+
if match_command_set_grant(component) is not None:
|
|
955
|
+
return False
|
|
956
|
+
except Exception:
|
|
957
|
+
pass
|
|
958
|
+
try:
|
|
959
|
+
if check_approval_grant(component, session_id=session_id) is not None:
|
|
960
|
+
return False
|
|
961
|
+
except Exception:
|
|
962
|
+
pass
|
|
963
|
+
return True
|
|
964
|
+
|
|
914
965
|
def _validate_compound_command(
|
|
915
966
|
self,
|
|
916
967
|
components: List[str],
|
|
@@ -918,9 +969,68 @@ class BashValidator:
|
|
|
918
969
|
session_id: str = "",
|
|
919
970
|
agent_type: str = "",
|
|
920
971
|
) -> BashValidationResult:
|
|
921
|
-
"""Validate a compound command (multiple components).
|
|
972
|
+
"""Validate a compound command (multiple components).
|
|
973
|
+
|
|
974
|
+
Chain COMMAND_SET intake (AC-8): when a chain ``a && b && c`` has TWO OR
|
|
975
|
+
MORE sub-commands that are ungranted T3, classifying them one-at-a-time
|
|
976
|
+
mints a single-signature pending for the FIRST and short-circuits -- so
|
|
977
|
+
one approval covers only the first sub-command and the next re-blocks
|
|
978
|
+
(the double-approval the user hit). To group them, a NON-MINTING
|
|
979
|
+
classification pass runs FIRST (``_is_ungranted_t3_component``); if >= 2
|
|
980
|
+
sub-commands are ungranted-T3 (and we are a subagent under the
|
|
981
|
+
orchestrator), ONE COMMAND_SET pending is minted over exactly those T3
|
|
982
|
+
sub-commands via ``decide_t3_outcome(command_set=...)``. One approval
|
|
983
|
+
then covers the chain; each sub-command is still consumed byte-for-byte
|
|
984
|
+
by its own signature at retry (no consent is widened -- the commands are
|
|
985
|
+
only grouped). Critically, the per-component minting path
|
|
986
|
+
(_validate_single_command) is NEVER entered for the batch, so no stray
|
|
987
|
+
single pendings are minted alongside the COMMAND_SET.
|
|
988
|
+
|
|
989
|
+
For every other shape (0 or 1 ungranted-T3, no orchestrator above, or a
|
|
990
|
+
component that is hard-blocked) the original per-component pass runs
|
|
991
|
+
unchanged: a hard block fails the chain fast, a lone T3 keeps the
|
|
992
|
+
singular grant path, and an all-granted/safe chain is allowed.
|
|
993
|
+
"""
|
|
922
994
|
logger.info(f"Compound command detected with {len(components)} components")
|
|
923
995
|
|
|
996
|
+
# NON-MINTING pre-pass: which components are ungranted T3? (AC-8)
|
|
997
|
+
if is_subagent and is_ops_mode():
|
|
998
|
+
ungranted_t3_idx = [
|
|
999
|
+
idx
|
|
1000
|
+
for idx, comp in enumerate(components)
|
|
1001
|
+
if self._is_ungranted_t3_component(comp, session_id)
|
|
1002
|
+
]
|
|
1003
|
+
if len(ungranted_t3_idx) >= 2:
|
|
1004
|
+
chain_set = [
|
|
1005
|
+
{"command": components[idx].strip(), "rationale": ""}
|
|
1006
|
+
for idx in ungranted_t3_idx
|
|
1007
|
+
]
|
|
1008
|
+
first_cmd = chain_set[0]["command"]
|
|
1009
|
+
first_detect = detect_mutative_command(first_cmd)
|
|
1010
|
+
verb = first_detect.verb or "command"
|
|
1011
|
+
category = first_detect.category or "MUTATIVE"
|
|
1012
|
+
native_ask_reason = (
|
|
1013
|
+
f"[T3_APPROVAL_REQUIRED] Chain of {len(chain_set)} T3 commands.\n"
|
|
1014
|
+
f"Commands:\n"
|
|
1015
|
+
+ "\n".join(f" - {it['command']}" for it in chain_set)
|
|
1016
|
+
)
|
|
1017
|
+
logger.info(
|
|
1018
|
+
"Chain COMMAND_SET intake: %d T3 sub-commands grouped under "
|
|
1019
|
+
"one consent (chain=%s)",
|
|
1020
|
+
len(chain_set),
|
|
1021
|
+
" && ".join(it["command"][:30] for it in chain_set),
|
|
1022
|
+
)
|
|
1023
|
+
return decide_t3_outcome(
|
|
1024
|
+
first_cmd,
|
|
1025
|
+
verb=verb,
|
|
1026
|
+
category=category,
|
|
1027
|
+
has_orchestrator_above=True,
|
|
1028
|
+
native_ask_reason=native_ask_reason,
|
|
1029
|
+
session_id=session_id,
|
|
1030
|
+
agent_type=agent_type,
|
|
1031
|
+
command_set=chain_set,
|
|
1032
|
+
)
|
|
1033
|
+
|
|
924
1034
|
component_results: List[BashValidationResult] = []
|
|
925
1035
|
for i, component in enumerate(components, 1):
|
|
926
1036
|
result = self._validate_single_command(
|
|
@@ -1385,6 +1495,7 @@ def decide_t3_outcome(
|
|
|
1385
1495
|
native_ask_reason: str,
|
|
1386
1496
|
session_id: str = "",
|
|
1387
1497
|
agent_type: str = "",
|
|
1498
|
+
command_set: list | None = None,
|
|
1388
1499
|
) -> BashValidationResult:
|
|
1389
1500
|
"""Single decision point for the outcome of a T3 (state-mutating) command.
|
|
1390
1501
|
|
|
@@ -1416,34 +1527,68 @@ def decide_t3_outcome(
|
|
|
1416
1527
|
native_ask_reason: Reason text for the native-ask fallback branch.
|
|
1417
1528
|
session_id: Session ID for pending-approval scoping.
|
|
1418
1529
|
agent_type: Originating agent name (for the sealed payload).
|
|
1530
|
+
command_set: Optional list of ``{command, rationale}`` dicts. When it
|
|
1531
|
+
carries MORE THAN ONE item, this T3 decision covers a chain
|
|
1532
|
+
(``a && b && c``) whose sub-commands are all T3, and the pending is
|
|
1533
|
+
minted as ONE COMMAND_SET envelope (the chain-intake path, AC-8)
|
|
1534
|
+
instead of a single semantic-signature pending. ONE user approval
|
|
1535
|
+
then covers the whole chain; each sub-command is still consumed
|
|
1536
|
+
byte-for-byte by its own signature at retry. A None / single-item
|
|
1537
|
+
set keeps the singular behaviour. Only honoured in the
|
|
1538
|
+
subagent-under-orchestrator branch (the native-ask branch has no
|
|
1539
|
+
COMMAND_SET concept).
|
|
1419
1540
|
|
|
1420
1541
|
Returns:
|
|
1421
1542
|
A blocked BashValidationResult (allowed=False, tier T3) whose
|
|
1422
1543
|
block_response is either a "deny" (with approval_id) or an "ask".
|
|
1423
1544
|
"""
|
|
1545
|
+
# A genuine multi-command chain is a set of >= 2 items. Anything else
|
|
1546
|
+
# collapses to the singular path so we never mint a COMMAND_SET for one
|
|
1547
|
+
# command (mirrors _build_sealed_payload's is_command_set guard).
|
|
1548
|
+
_normalized_set: list = []
|
|
1549
|
+
if command_set:
|
|
1550
|
+
for _item in command_set:
|
|
1551
|
+
if isinstance(_item, dict) and _item.get("command"):
|
|
1552
|
+
_normalized_set.append(
|
|
1553
|
+
{
|
|
1554
|
+
"command": _item["command"],
|
|
1555
|
+
"rationale": _item.get("rationale", ""),
|
|
1556
|
+
}
|
|
1557
|
+
)
|
|
1558
|
+
is_chain_command_set = len(_normalized_set) > 1
|
|
1559
|
+
|
|
1424
1560
|
if has_orchestrator_above:
|
|
1425
1561
|
# Subagent-under-orchestrator: deny + persisted approval_id so the
|
|
1426
1562
|
# orchestrator can run the approval cycle. Reuse an existing pending
|
|
1427
1563
|
# approval on retry to avoid generating duplicates while the user reviews.
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1564
|
+
#
|
|
1565
|
+
# For a COMMAND_SET chain the pending id is CONTENT-derived (matching the
|
|
1566
|
+
# plan-first intake), so a retry of the same chain produces the same id
|
|
1567
|
+
# and the fingerprint-dedup in insert_requested reuses the pending. The
|
|
1568
|
+
# singular reuse probe (_find_pending_in_db) matches a SINGLE command's
|
|
1569
|
+
# signature and must NOT be consulted for the chain -- it would match one
|
|
1570
|
+
# leftover single pending of a sub-command and degrade the chain back to
|
|
1571
|
+
# a single grant. So the chain path skips it entirely.
|
|
1572
|
+
if not is_chain_command_set:
|
|
1573
|
+
approval_id = _find_pending_in_db(session_id or "", command)
|
|
1574
|
+
if approval_id:
|
|
1575
|
+
logger.info(
|
|
1576
|
+
"Reusing pending approval_id=%s for retry: %s",
|
|
1577
|
+
approval_id, command[:80],
|
|
1578
|
+
)
|
|
1579
|
+
reason = build_t3_blocked_denial_message(
|
|
1580
|
+
approval_id=approval_id,
|
|
1581
|
+
command=command,
|
|
1582
|
+
verb=verb,
|
|
1583
|
+
category=category,
|
|
1584
|
+
)
|
|
1585
|
+
hook_deny = build_hook_permission_response("deny", reason)
|
|
1586
|
+
return BashValidationResult(
|
|
1587
|
+
allowed=False,
|
|
1588
|
+
tier=SecurityTier.T3_BLOCKED,
|
|
1589
|
+
reason=f"T3 {category.lower()} command: {command[:60]}",
|
|
1590
|
+
block_response=hook_deny,
|
|
1591
|
+
)
|
|
1447
1592
|
|
|
1448
1593
|
# No existing pending -- insert via DB (D16: exclusive path).
|
|
1449
1594
|
sealed_payload = _build_sealed_payload(
|
|
@@ -1451,13 +1596,25 @@ def decide_t3_outcome(
|
|
|
1451
1596
|
verb=verb,
|
|
1452
1597
|
category=category,
|
|
1453
1598
|
agent_type=agent_type,
|
|
1599
|
+
command_set=_normalized_set if is_chain_command_set else None,
|
|
1454
1600
|
)
|
|
1455
1601
|
try:
|
|
1456
1602
|
from gaia.approvals.store import insert_requested
|
|
1603
|
+
# COMMAND_SET chains use a CONTENT-derived id (deterministic over the
|
|
1604
|
+
# sub-command list) so a retry of the same chain reproduces the same
|
|
1605
|
+
# id and reuses the pending via fingerprint dedup -- identical to the
|
|
1606
|
+
# plan-first intake in handoff_persister. Singular T3 keeps uuid4.
|
|
1607
|
+
supplied_id = None
|
|
1608
|
+
if is_chain_command_set:
|
|
1609
|
+
from gaia.approvals.store import derive_command_set_id
|
|
1610
|
+
supplied_id = derive_command_set_id(
|
|
1611
|
+
[it["command"] for it in _normalized_set]
|
|
1612
|
+
)
|
|
1457
1613
|
approval_id = insert_requested(
|
|
1458
1614
|
sealed_payload,
|
|
1459
1615
|
agent_id=agent_type or None,
|
|
1460
1616
|
session_id=session_id or None,
|
|
1617
|
+
approval_id=supplied_id,
|
|
1461
1618
|
)
|
|
1462
1619
|
except Exception as _store_err:
|
|
1463
1620
|
logger.warning(
|
|
@@ -42,7 +42,7 @@ The runtime, not this skill, enforces tiers. Three modules layer the decision:
|
|
|
42
42
|
|
|
43
43
|
- `tiers.py` -- the `SecurityTier` enum (`T0_READ_ONLY`, `T1_VALIDATION`, `T2_DRY_RUN`, `T3_BLOCKED`) and `_classify_command_tier_cached` assign every command a tier.
|
|
44
44
|
- `blocked_commands.py` -- pattern-matches irreversible commands and permanently denies them (exit 2, never approvable).
|
|
45
|
-
- `mutative_verbs.py` -- CLI-agnostic detection of mutative verbs; drives the nonce / approval flow for T3.
|
|
45
|
+
- `mutative_verbs.py` -- CLI-agnostic detection of mutative verbs; drives the nonce / approval flow for T3. Includes script-file detection (Step 1d, `_check_script_file`): when a command is `<interpreter> <script-file>` (`python3 deploy.py`, `bash setup.sh`, `node migrate.js`) or `./script.ext`, the file is read and classified by its real invocations -- AST analysis for Python, the blocked/mutative regex layer for shells and other interpreters. A script that is missing, unreadable, or whose interpreter is unrecognized defaults to T3 (conservative). This prevents the evasion path where `<interp> <file>` bypasses the verb scanner because the filename token has no recognizable subcommand.
|
|
46
46
|
- `composition_rules.py` -- `check_composition` / `classify_stage` classify pipe compositions (FILE_READ→EXEC_SINK, network→exec, decode→exec); triggers T3 on dangerous pipelines such as `file_to_exec`.
|
|
47
47
|
- `flag_classifiers.py` -- `_classify_curl` / `classify_by_flags` detect flag-dependent mutations; triggers T3 on commands whose flags make them mutative (e.g., `curl -X POST`).
|
|
48
48
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gaia-security",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.10",
|
|
4
4
|
"description": "Keeps you in the loop only when it matters. Gaia Security analyzes every command and classifies it into risk tiers: read-only queries run freely, simulations and validations pass through, and state-changing operations (create, delete, apply, push) pause for your explicit approval before executing. Irreversible commands like dropping databases or deleting cloud infrastructure are permanently blocked.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "jaguilar87",
|
|
@@ -347,11 +347,6 @@ def setup_project_permissions() -> bool:
|
|
|
347
347
|
existing["permissions"]["deny"] = merged_deny
|
|
348
348
|
existing["permissions"].setdefault("ask", [])
|
|
349
349
|
|
|
350
|
-
# Add env vars (smart merge: add if not present, don't overwrite)
|
|
351
|
-
env = existing.setdefault("env", {})
|
|
352
|
-
if "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS" not in env:
|
|
353
|
-
env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1"
|
|
354
|
-
|
|
355
350
|
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
356
351
|
settings_path.write_text(json.dumps(existing, indent=2) + "\n")
|
|
357
352
|
logger.info("Merged gaia %s permissions and env into %s", mode, settings_path)
|
|
@@ -42,12 +42,21 @@ as follows:
|
|
|
42
42
|
1. If a redirect-input token (``<``) or a pipe-input is present, the
|
|
43
43
|
payload is considered external and uninspected -- keep MUTATIVE.
|
|
44
44
|
2. If a positional argument starts with a sqlite-style dot-command that
|
|
45
|
-
loads a script (``.read``, ``.import``,
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
loads or executes a script / writes to disk (``.read``, ``.import``,
|
|
46
|
+
``.restore``, ``.clone``, ``.load``, ``.system``, ``.shell``, ``.save``),
|
|
47
|
+
keep MUTATIVE.
|
|
48
|
+
3. If every dot-command present is a strictly read-only sqlite3 schema /
|
|
49
|
+
metadata command (``.schema``, ``.tables``, ``.databases``,
|
|
50
|
+
``.indexes`` / ``.indices``, ``.dbinfo``, ``.show``, ``.fullschema``),
|
|
51
|
+
classify as READ_ONLY. This check runs *after* rule 2, so the
|
|
52
|
+
write-capable dot-commands above are caught first and never downgraded;
|
|
53
|
+
``.dump`` / ``.output`` / ``.once`` / ``.backup`` are deliberately left
|
|
54
|
+
out of the read-only set (conservative) and fall through to MUTATIVE.
|
|
55
|
+
4. If a flag override matches (e.g. ``-readonly``), classify as READ_ONLY.
|
|
56
|
+
5. If the command exposes an inline payload via a recognised flag pair
|
|
48
57
|
(``-c``, ``-e``, ``--eval``) and the payload matches the read-only
|
|
49
58
|
regex, classify as READ_ONLY.
|
|
50
|
-
|
|
59
|
+
6. Otherwise return ``default_intent`` (MUTATIVE).
|
|
51
60
|
|
|
52
61
|
A future Nivel 2 (`sql_payload_analyzer.py`) will parse external SQL files
|
|
53
62
|
and inline payloads into an AST and downgrade more cases -- e.g., a file
|
|
@@ -105,6 +114,29 @@ _SQLITE_MUTATIVE_DOT_COMMANDS: FrozenSet[str] = frozenset({
|
|
|
105
114
|
".read", ".import", ".restore", ".clone", ".load", ".system", ".shell", ".save",
|
|
106
115
|
})
|
|
107
116
|
|
|
117
|
+
#: SQLite dot-commands that are strictly read-only schema/metadata introspection.
|
|
118
|
+
#: These produce no side effects on the database file and write nothing to disk.
|
|
119
|
+
#:
|
|
120
|
+
#: NOT included (remain MUTATIVE):
|
|
121
|
+
#: .import, .restore, .backup, .clone, .save -- write to db/file
|
|
122
|
+
#: .read -- executes an arbitrary script
|
|
123
|
+
#: .output / .once -- redirects output to a file
|
|
124
|
+
#: .load -- loads a native extension (exec)
|
|
125
|
+
#: .system / .shell -- arbitrary OS command execution
|
|
126
|
+
#: .dump -- NOT included: commonly piped to
|
|
127
|
+
#: files and by default prints the
|
|
128
|
+
#: full db; conservative exclusion.
|
|
129
|
+
_SQLITE_READONLY_DOT_COMMANDS: FrozenSet[str] = frozenset({
|
|
130
|
+
".schema", # prints CREATE statements for tables/indexes
|
|
131
|
+
".tables", # lists tables in the database
|
|
132
|
+
".databases", # lists attached databases
|
|
133
|
+
".indexes", # lists indexes for a table or all tables
|
|
134
|
+
".indices", # alias for .indexes
|
|
135
|
+
".dbinfo", # prints low-level metadata about the db file
|
|
136
|
+
".show", # prints current settings (not data)
|
|
137
|
+
".fullschema", # prints CREATE statements including schema_table
|
|
138
|
+
})
|
|
139
|
+
|
|
108
140
|
#: Tokens shlex emits for unquoted shell redirects. Their presence in the
|
|
109
141
|
#: positional argument stream means the inline command was fed from an
|
|
110
142
|
#: external source -- the payload is uninspected at Nivel 1.
|
|
@@ -258,6 +290,29 @@ def _has_sqlite_load_dot_command(tokens: Tuple[str, ...]) -> bool:
|
|
|
258
290
|
return False
|
|
259
291
|
|
|
260
292
|
|
|
293
|
+
def _has_sqlite_readonly_dot_command(tokens: Tuple[str, ...]) -> bool:
|
|
294
|
+
"""Return True when ALL dot-commands present in the tokens are
|
|
295
|
+
strictly read-only schema/metadata commands.
|
|
296
|
+
|
|
297
|
+
Returns False (falls through) when no dot-command is present so the
|
|
298
|
+
regular inline-payload and default rules continue to apply.
|
|
299
|
+
Returns False when a dot-command outside the read-only allowlist is
|
|
300
|
+
found -- the caller should treat those as MUTATIVE.
|
|
301
|
+
"""
|
|
302
|
+
dot_cmds_found = []
|
|
303
|
+
for tok in tokens:
|
|
304
|
+
stripped = tok.strip().strip('"').strip("'")
|
|
305
|
+
first_word = stripped.split(None, 1)[0] if stripped else ""
|
|
306
|
+
if first_word.startswith("."):
|
|
307
|
+
dot_cmds_found.append(first_word.lower())
|
|
308
|
+
|
|
309
|
+
if not dot_cmds_found:
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
# Every dot-command present must be in the read-only set.
|
|
313
|
+
return all(cmd in _SQLITE_READONLY_DOT_COMMANDS for cmd in dot_cmds_found)
|
|
314
|
+
|
|
315
|
+
|
|
261
316
|
# ============================================================================
|
|
262
317
|
# Main entry point
|
|
263
318
|
# ============================================================================
|
|
@@ -271,8 +326,16 @@ def classify_capability(semantics: CommandSemantics) -> CapabilityResult:
|
|
|
271
326
|
|
|
272
327
|
Resolution order (mirrors module docstring):
|
|
273
328
|
|
|
274
|
-
1. External payload (redirect ``<``
|
|
275
|
-
|
|
329
|
+
1. External payload (redirect ``<``) -> MUTATIVE.
|
|
330
|
+
1b. sqlite write-capable dot-command (``.read`` / ``.import`` /
|
|
331
|
+
``.restore`` / ``.clone`` / ``.load`` / ``.system`` / ``.shell`` /
|
|
332
|
+
``.save``) -> MUTATIVE.
|
|
333
|
+
1c. sqlite read-only schema/metadata dot-command (``.schema`` /
|
|
334
|
+
``.tables`` / ``.databases`` / ``.indexes`` / ``.indices`` /
|
|
335
|
+
``.dbinfo`` / ``.show`` / ``.fullschema``) -> READ_ONLY. Runs after
|
|
336
|
+
1b so write-capable dot-commands are never downgraded; ``.dump`` /
|
|
337
|
+
``.output`` / ``.once`` / ``.backup`` are excluded (conservative)
|
|
338
|
+
and fall through to the default.
|
|
276
339
|
2. Flag override -> READ_ONLY.
|
|
277
340
|
3. Inline-payload override -> READ_ONLY.
|
|
278
341
|
4. Default -> ``default_intent`` (always MUTATIVE today).
|
|
@@ -314,6 +377,20 @@ def classify_capability(semantics: CommandSemantics) -> CapabilityResult:
|
|
|
314
377
|
),
|
|
315
378
|
)
|
|
316
379
|
|
|
380
|
+
# --- Rule 1c: sqlite read-only dot-commands -> READ_ONLY ----------------
|
|
381
|
+
# Must run after the mutative-dot-command check so that write-capable
|
|
382
|
+
# dot-commands (.read, .import, ...) are never downgraded here.
|
|
383
|
+
if base_cmd in {"sqlite3", "sqlite"} and _has_sqlite_readonly_dot_command(tokens):
|
|
384
|
+
return CapabilityResult(
|
|
385
|
+
matched=True,
|
|
386
|
+
capability_class=class_name,
|
|
387
|
+
intent=CATEGORY_READ_ONLY,
|
|
388
|
+
reason=(
|
|
389
|
+
f"{class_name}: sqlite dot-command is a read-only schema/metadata "
|
|
390
|
+
"introspection command (.schema / .tables / .databases / ...)"
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
|
|
317
394
|
# --- Rule 2: flag-based overrides ---------------------------------------
|
|
318
395
|
flag_overrides = [
|
|
319
396
|
rule["flag"] for rule in overrides
|
|
@@ -38,6 +38,7 @@ from __future__ import annotations
|
|
|
38
38
|
|
|
39
39
|
import ast
|
|
40
40
|
import logging
|
|
41
|
+
import re
|
|
41
42
|
from dataclasses import dataclass
|
|
42
43
|
from typing import FrozenSet, Optional, Set, Tuple
|
|
43
44
|
|
|
@@ -239,6 +240,242 @@ def analyze_python_inline(code: str) -> InlineAstResult:
|
|
|
239
240
|
return InlineAstResult()
|
|
240
241
|
|
|
241
242
|
|
|
243
|
+
# ============================================================================
|
|
244
|
+
# Provable read-only classification (positive allowlist)
|
|
245
|
+
# ============================================================================
|
|
246
|
+
# Rationale: ``analyze_python_inline`` uses a *blocklist* — a clean result
|
|
247
|
+
# means "no KNOWN dangerous call was found", which is NOT the same as
|
|
248
|
+
# "read-only". Bound-method mutations the catalog cannot see statically —
|
|
249
|
+
# ``cur.execute("INSERT ...")``, ``con.commit()``, ``f.write(...)`` on a
|
|
250
|
+
# handle whose write-mode was set elsewhere — parse cleanly yet mutate.
|
|
251
|
+
#
|
|
252
|
+
# This second classifier exists ONLY to safely exempt long-but-harmless
|
|
253
|
+
# inline code from the length heuristic (``heuristic-long-code``). It is the
|
|
254
|
+
# inverse discipline: it returns True ONLY when EVERY statement and EVERY call
|
|
255
|
+
# in the payload is on a positive read-only allowlist. Anything unrecognized
|
|
256
|
+
# — any node type, call target, assignment target, or SQL verb it cannot
|
|
257
|
+
# prove safe — makes it return False, leaving the length heuristic in force.
|
|
258
|
+
# No-false-negative is the contract: a mutation must never be classified
|
|
259
|
+
# read-only, even at the cost of leaving some genuinely-read-only payloads
|
|
260
|
+
# subject to the length flag (those remain T3-approvable, never silently run).
|
|
261
|
+
|
|
262
|
+
# Builtins that never mutate external state. Deliberately conservative:
|
|
263
|
+
# ``open`` is excluded (write modes), ``exec``/``eval``/``compile``/
|
|
264
|
+
# ``__import__``/``input``/``getattr``/``setattr``/``delattr`` are excluded
|
|
265
|
+
# (dynamic dispatch defeats static analysis), ``print`` is allowed (stdout
|
|
266
|
+
# only).
|
|
267
|
+
_READ_ONLY_BUILTINS: FrozenSet[str] = frozenset({
|
|
268
|
+
"print", "len", "str", "repr", "int", "float", "bool", "list", "tuple",
|
|
269
|
+
"dict", "set", "frozenset", "sorted", "reversed", "enumerate", "zip",
|
|
270
|
+
"map", "filter", "range", "sum", "min", "max", "abs", "round", "any",
|
|
271
|
+
"all", "format", "ascii", "bin", "hex", "oct", "ord", "chr", "type",
|
|
272
|
+
"isinstance", "issubclass", "hasattr", "iter", "next", "bytes",
|
|
273
|
+
"bytearray", "id", "hash", "divmod", "pow", "vars", "dir",
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
# Read-only methods, matched by leaf attribute name regardless of receiver.
|
|
277
|
+
# These are common DB-cursor / mapping / sequence / string read accessors.
|
|
278
|
+
# ``execute``/``executemany``/``executescript`` are handled SEPARATELY: they
|
|
279
|
+
# are allowed ONLY when the SQL argument is a literal read-only statement.
|
|
280
|
+
_READ_ONLY_METHODS: FrozenSet[str] = frozenset({
|
|
281
|
+
# DB cursor/connection read surface
|
|
282
|
+
"fetchone", "fetchall", "fetchmany", "cursor", "close",
|
|
283
|
+
# mapping / sequence reads
|
|
284
|
+
"keys", "values", "items", "get", "copy", "index", "count",
|
|
285
|
+
# string reads
|
|
286
|
+
"strip", "lstrip", "rstrip", "split", "rsplit", "splitlines", "join",
|
|
287
|
+
"lower", "upper", "title", "capitalize", "startswith", "endswith",
|
|
288
|
+
"find", "rfind", "format", "encode", "decode", "replace", "zfill",
|
|
289
|
+
"ljust", "rjust", "center",
|
|
290
|
+
# iteration / misc pure reads
|
|
291
|
+
"isoformat", "total_seconds", "group", "groups", "groupdict", "read",
|
|
292
|
+
"readline", "readlines",
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
# SQL statement prefixes that are read-only. Matched case-insensitively
|
|
296
|
+
# against the leading token of a literal SQL string. ``WITH`` (CTE) is
|
|
297
|
+
# allowed only when it ultimately SELECTs — but a CTE can wrap an
|
|
298
|
+
# INSERT/UPDATE/DELETE (``WITH x AS (...) DELETE ...``), so to stay airtight
|
|
299
|
+
# we require the literal to ALSO contain no mutating keyword. Simpler and
|
|
300
|
+
# safer: allow the prefix, then reject if any mutating keyword appears
|
|
301
|
+
# anywhere in the literal.
|
|
302
|
+
_READ_ONLY_SQL_PREFIXES: Tuple[str, ...] = (
|
|
303
|
+
"select", "pragma", "explain", "with", "values", "show",
|
|
304
|
+
)
|
|
305
|
+
_MUTATING_SQL_KEYWORDS: Tuple[str, ...] = (
|
|
306
|
+
"insert", "update", "delete", "drop", "create", "alter", "replace",
|
|
307
|
+
"truncate", "attach", "detach", "vacuum", "reindex", "commit",
|
|
308
|
+
"rollback", "savepoint", "grant", "revoke", "merge", "upsert", "begin",
|
|
309
|
+
)
|
|
310
|
+
_SQL_EXEC_METHODS: FrozenSet[str] = frozenset({
|
|
311
|
+
"execute", "executemany", "executescript",
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def is_provably_read_only_python(code: str) -> bool:
|
|
316
|
+
"""Return True ONLY if every construct in ``code`` is provably read-only.
|
|
317
|
+
|
|
318
|
+
Positive allowlist over the AST. Any node type, call, assignment target,
|
|
319
|
+
or SQL verb that cannot be proven safe returns False. Used to exempt
|
|
320
|
+
long-but-harmless inline code from the length heuristic; never used to
|
|
321
|
+
grant execution by itself.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
code: Python source extracted from ``python3 -c "..."`` (unquoted).
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
True when the payload contains exclusively read-only constructs;
|
|
328
|
+
False on any uncertainty (including parse failure).
|
|
329
|
+
"""
|
|
330
|
+
if not code or not code.strip():
|
|
331
|
+
# Empty payload: nothing to exempt; let caller's default path handle.
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
tree = ast.parse(code, mode="exec")
|
|
336
|
+
except SyntaxError:
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
checker = _ReadOnlyChecker()
|
|
340
|
+
return checker.is_read_only(tree)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class _ReadOnlyChecker:
|
|
344
|
+
"""Walks an AST and proves it contains only read-only constructs."""
|
|
345
|
+
|
|
346
|
+
# Statement node types that are structurally inert (control flow,
|
|
347
|
+
# definitions, expression evaluation). Mutation can only happen via a
|
|
348
|
+
# Call, an attribute/subscript assignment, del, or import side effects —
|
|
349
|
+
# all handled explicitly below.
|
|
350
|
+
_ALLOWED_STMT_TYPES = (
|
|
351
|
+
ast.Import, ast.ImportFrom, ast.Expr, ast.Assign, ast.AnnAssign,
|
|
352
|
+
ast.AugAssign, ast.For, ast.While, ast.If, ast.With, ast.FunctionDef,
|
|
353
|
+
ast.Return, ast.Pass, ast.Break, ast.Continue, ast.Assert,
|
|
354
|
+
ast.AsyncFunctionDef, ast.AsyncFor, ast.AsyncWith,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def is_read_only(self, tree: ast.AST) -> bool:
|
|
358
|
+
for node in ast.walk(tree):
|
|
359
|
+
# Reject statements we do not explicitly allow.
|
|
360
|
+
if isinstance(node, ast.stmt):
|
|
361
|
+
if not isinstance(node, self._ALLOWED_STMT_TYPES):
|
|
362
|
+
return False
|
|
363
|
+
# ``del`` removes bindings / can call __delitem__/__delattr__.
|
|
364
|
+
if isinstance(node, ast.Delete):
|
|
365
|
+
return False
|
|
366
|
+
# Assignment targets must be plain names or name-tuples. A
|
|
367
|
+
# Subscript or Attribute target (``os.environ[k]=v``,
|
|
368
|
+
# ``obj.attr=v``) can mutate external state via __setitem__ /
|
|
369
|
+
# __setattr__.
|
|
370
|
+
if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
|
|
371
|
+
if not self._targets_are_local(node):
|
|
372
|
+
return False
|
|
373
|
+
# Every call must be on the allowlist.
|
|
374
|
+
if isinstance(node, ast.Call):
|
|
375
|
+
if not self._call_is_read_only(node):
|
|
376
|
+
return False
|
|
377
|
+
# ``with`` items: the context manager is itself a Call/expr and is
|
|
378
|
+
# validated by the Call check above; nothing extra needed.
|
|
379
|
+
return True
|
|
380
|
+
|
|
381
|
+
def _targets_are_local(self, node: ast.AST) -> bool:
|
|
382
|
+
targets = []
|
|
383
|
+
if isinstance(node, ast.Assign):
|
|
384
|
+
targets = node.targets
|
|
385
|
+
elif isinstance(node, (ast.AnnAssign, ast.AugAssign)):
|
|
386
|
+
targets = [node.target]
|
|
387
|
+
for tgt in targets:
|
|
388
|
+
if not self._is_local_target(tgt):
|
|
389
|
+
return False
|
|
390
|
+
return True
|
|
391
|
+
|
|
392
|
+
def _is_local_target(self, tgt: ast.AST) -> bool:
|
|
393
|
+
if isinstance(tgt, ast.Name):
|
|
394
|
+
return True
|
|
395
|
+
if isinstance(tgt, (ast.Tuple, ast.List)):
|
|
396
|
+
return all(self._is_local_target(e) for e in tgt.elts)
|
|
397
|
+
if isinstance(tgt, ast.Starred):
|
|
398
|
+
return self._is_local_target(tgt.value)
|
|
399
|
+
# Subscript / Attribute targets can mutate external state.
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
def _call_is_read_only(self, node: ast.Call) -> bool:
|
|
403
|
+
func = node.func
|
|
404
|
+
# Bare name call: must be a read-only builtin. (Unresolved local
|
|
405
|
+
# function calls are rejected — we cannot prove their body is safe.)
|
|
406
|
+
if isinstance(func, ast.Name):
|
|
407
|
+
return func.id in _READ_ONLY_BUILTINS
|
|
408
|
+
# Attribute call: ``x.method(...)``.
|
|
409
|
+
if isinstance(func, ast.Attribute):
|
|
410
|
+
method = func.attr
|
|
411
|
+
if method in _SQL_EXEC_METHODS:
|
|
412
|
+
return self._sql_arg_is_read_only(node)
|
|
413
|
+
if method in _READ_ONLY_METHODS:
|
|
414
|
+
return True
|
|
415
|
+
# Allow ``sqlite3.connect(...)`` and module-qualified pure reads
|
|
416
|
+
# we can name explicitly; everything else is rejected.
|
|
417
|
+
return self._dotted_call_is_read_only(func)
|
|
418
|
+
# Any other callable form (subscript result, lambda, call chain head)
|
|
419
|
+
# cannot be proven safe.
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
def _dotted_call_is_read_only(self, func: ast.Attribute) -> bool:
|
|
423
|
+
# Build dotted source name (best-effort). Only a tiny set of
|
|
424
|
+
# module-level read-only constructors are permitted.
|
|
425
|
+
parts = []
|
|
426
|
+
cur: ast.AST = func
|
|
427
|
+
while isinstance(cur, ast.Attribute):
|
|
428
|
+
parts.append(cur.attr)
|
|
429
|
+
cur = cur.value
|
|
430
|
+
if isinstance(cur, ast.Name):
|
|
431
|
+
parts.append(cur.id)
|
|
432
|
+
parts.reverse()
|
|
433
|
+
dotted = ".".join(parts)
|
|
434
|
+
return dotted in _READ_ONLY_DOTTED_CALLS
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
def _sql_arg_is_read_only(self, node: ast.Call) -> bool:
|
|
438
|
+
# ``execute``/``executemany`` are read-only ONLY when the first
|
|
439
|
+
# positional argument is a string LITERAL whose leading token is a
|
|
440
|
+
# read-only SQL verb AND which contains no mutating keyword. A
|
|
441
|
+
# non-literal SQL argument (variable, f-string, concatenation) cannot
|
|
442
|
+
# be proven safe and is rejected.
|
|
443
|
+
if not node.args:
|
|
444
|
+
return False
|
|
445
|
+
first = node.args[0]
|
|
446
|
+
if not (isinstance(first, ast.Constant) and isinstance(first.value, str)):
|
|
447
|
+
return False
|
|
448
|
+
sql = first.value.strip().lower()
|
|
449
|
+
if not sql:
|
|
450
|
+
return False
|
|
451
|
+
# Strip a leading comment / whitespace already done; take first word.
|
|
452
|
+
leading = sql.split(None, 1)[0] if sql.split() else ""
|
|
453
|
+
if leading not in _READ_ONLY_SQL_PREFIXES:
|
|
454
|
+
return False
|
|
455
|
+
# Reject if ANY mutating keyword appears anywhere (defeats CTE-wrapped
|
|
456
|
+
# writes like ``WITH x AS (...) DELETE ...`` and stacked statements).
|
|
457
|
+
for kw in _MUTATING_SQL_KEYWORDS:
|
|
458
|
+
if re.search(r"\b" + re.escape(kw) + r"\b", sql):
|
|
459
|
+
return False
|
|
460
|
+
return True
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# Module-level read-only constructors permitted in a provable-read-only
|
|
464
|
+
# payload (dotted source names). ``sqlite3.connect`` opens a handle;
|
|
465
|
+
# mutation would require a subsequent write call, which is independently
|
|
466
|
+
# checked. Kept deliberately small.
|
|
467
|
+
_READ_ONLY_DOTTED_CALLS: FrozenSet[str] = frozenset({
|
|
468
|
+
"sqlite3.connect",
|
|
469
|
+
"json.dumps", "json.loads", "json.load",
|
|
470
|
+
"os.getcwd", "os.getenv", "os.listdir", "os.path.join", "os.path.exists",
|
|
471
|
+
"os.path.basename", "os.path.dirname", "os.path.abspath",
|
|
472
|
+
"os.path.isfile", "os.path.isdir", "os.path.getsize",
|
|
473
|
+
"sys.exit",
|
|
474
|
+
"datetime.now", "datetime.utcnow", "time.time",
|
|
475
|
+
"pathlib.Path", "Path",
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
|
|
242
479
|
# ============================================================================
|
|
243
480
|
# Internal helpers
|
|
244
481
|
# ============================================================================
|