@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
|
@@ -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
|
# ============================================================================
|