@jaguilar87/gaia 5.0.9 → 5.0.11

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 (104) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +17 -0
  4. package/bin/README.md +4 -2
  5. package/bin/cli/_install_helpers.py +0 -3
  6. package/bin/cli/ac.py +2 -2
  7. package/bin/cli/brief.py +42 -7
  8. package/bin/cli/cleanup.py +304 -4
  9. package/bin/cli/doctor.py +1 -5
  10. package/bin/cli/uninstall.py +20 -0
  11. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  12. package/dist/gaia-ops/hooks/adapters/__init__.py +12 -2
  13. package/dist/gaia-ops/hooks/adapters/base.py +122 -5
  14. package/dist/gaia-ops/hooks/adapters/claude_code.py +175 -53
  15. package/dist/gaia-ops/hooks/adapters/host_session.py +53 -0
  16. package/dist/gaia-ops/hooks/adapters/host_transcript.py +75 -0
  17. package/dist/gaia-ops/hooks/adapters/registry.py +87 -0
  18. package/dist/gaia-ops/hooks/adapters/types.py +134 -6
  19. package/dist/gaia-ops/hooks/modules/agents/transcript_reader.py +34 -71
  20. package/dist/gaia-ops/hooks/modules/core/hook_entry.py +6 -4
  21. package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +0 -5
  22. package/dist/gaia-ops/hooks/modules/core/state.py +12 -10
  23. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +2 -2
  24. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +7 -7
  25. package/dist/gaia-ops/hooks/modules/security/capability_classes.py +83 -6
  26. package/dist/gaia-ops/hooks/modules/security/inline_ast_analyzer.py +237 -0
  27. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +414 -3
  28. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +4 -3
  29. package/dist/gaia-ops/hooks/modules/session/session_manager.py +6 -15
  30. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +3 -3
  31. package/dist/gaia-ops/hooks/modules/session/session_registry.py +3 -3
  32. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +191 -32
  33. package/dist/gaia-ops/hooks/modules/tools/hook_response.py +14 -12
  34. package/dist/gaia-ops/hooks/post_tool_use.py +2 -2
  35. package/dist/gaia-ops/hooks/pre_tool_use.py +9 -8
  36. package/dist/gaia-ops/hooks/stop_hook.py +2 -2
  37. package/dist/gaia-ops/hooks/subagent_start.py +2 -2
  38. package/dist/gaia-ops/hooks/subagent_stop.py +2 -2
  39. package/dist/gaia-ops/hooks/task_completed.py +2 -2
  40. package/dist/gaia-ops/skills/security-tiers/SKILL.md +1 -1
  41. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  42. package/dist/gaia-security/hooks/adapters/__init__.py +12 -2
  43. package/dist/gaia-security/hooks/adapters/base.py +122 -5
  44. package/dist/gaia-security/hooks/adapters/claude_code.py +175 -53
  45. package/dist/gaia-security/hooks/adapters/host_session.py +53 -0
  46. package/dist/gaia-security/hooks/adapters/host_transcript.py +75 -0
  47. package/dist/gaia-security/hooks/adapters/registry.py +87 -0
  48. package/dist/gaia-security/hooks/adapters/types.py +134 -6
  49. package/dist/gaia-security/hooks/modules/agents/transcript_reader.py +34 -71
  50. package/dist/gaia-security/hooks/modules/core/hook_entry.py +6 -4
  51. package/dist/gaia-security/hooks/modules/core/plugin_setup.py +0 -5
  52. package/dist/gaia-security/hooks/modules/core/state.py +12 -10
  53. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +2 -2
  54. package/dist/gaia-security/hooks/modules/security/approval_grants.py +7 -7
  55. package/dist/gaia-security/hooks/modules/security/capability_classes.py +83 -6
  56. package/dist/gaia-security/hooks/modules/security/inline_ast_analyzer.py +237 -0
  57. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +414 -3
  58. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +4 -3
  59. package/dist/gaia-security/hooks/modules/session/session_manager.py +6 -15
  60. package/dist/gaia-security/hooks/modules/session/session_manifest.py +3 -3
  61. package/dist/gaia-security/hooks/modules/session/session_registry.py +3 -3
  62. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +191 -32
  63. package/dist/gaia-security/hooks/modules/tools/hook_response.py +14 -12
  64. package/dist/gaia-security/hooks/post_tool_use.py +2 -2
  65. package/dist/gaia-security/hooks/pre_tool_use.py +9 -8
  66. package/dist/gaia-security/hooks/stop_hook.py +2 -2
  67. package/gaia/briefs/__init__.py +4 -0
  68. package/gaia/briefs/store.py +144 -1
  69. package/gaia/state/__init__.py +8 -1
  70. package/gaia/state/transitions.py +18 -4
  71. package/gaia/store/schema.sql +5 -1
  72. package/hooks/adapters/__init__.py +12 -2
  73. package/hooks/adapters/base.py +122 -5
  74. package/hooks/adapters/claude_code.py +175 -53
  75. package/hooks/adapters/host_session.py +53 -0
  76. package/hooks/adapters/host_transcript.py +75 -0
  77. package/hooks/adapters/registry.py +87 -0
  78. package/hooks/adapters/types.py +134 -6
  79. package/hooks/modules/agents/transcript_reader.py +34 -71
  80. package/hooks/modules/core/hook_entry.py +6 -4
  81. package/hooks/modules/core/plugin_setup.py +0 -5
  82. package/hooks/modules/core/state.py +12 -10
  83. package/hooks/modules/security/approval_cleanup.py +2 -2
  84. package/hooks/modules/security/approval_grants.py +7 -7
  85. package/hooks/modules/security/capability_classes.py +83 -6
  86. package/hooks/modules/security/inline_ast_analyzer.py +237 -0
  87. package/hooks/modules/security/mutative_verbs.py +414 -3
  88. package/hooks/modules/session/pending_scanner.py +4 -3
  89. package/hooks/modules/session/session_manager.py +6 -15
  90. package/hooks/modules/session/session_manifest.py +3 -3
  91. package/hooks/modules/session/session_registry.py +3 -3
  92. package/hooks/modules/tools/bash_validator.py +191 -32
  93. package/hooks/modules/tools/hook_response.py +14 -12
  94. package/hooks/post_tool_use.py +2 -2
  95. package/hooks/pre_tool_use.py +9 -8
  96. package/hooks/stop_hook.py +2 -2
  97. package/hooks/subagent_start.py +2 -2
  98. package/hooks/subagent_stop.py +2 -2
  99. package/hooks/task_completed.py +2 -2
  100. package/package.json +1 -1
  101. package/pyproject.toml +20 -1
  102. package/scripts/migrations/schema.checksum +2 -2
  103. package/scripts/migrations/v20_to_v21.sql +68 -0
  104. package/skills/security-tiers/SKILL.md +1 -1
@@ -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``, ``.restore``), keep MUTATIVE.
46
- 3. If a flag override matches (e.g. ``-readonly``), classify as READ_ONLY.
47
- 4. If the command exposes an inline payload via a recognised flag pair
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
- 5. Otherwise return ``default_intent`` (MUTATIVE).
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 ``<`` or sqlite ``.read``-style command)
275
- -> MUTATIVE.
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
  # ============================================================================