@jaguilar87/gaia 5.0.8 → 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 +13 -0
- package/bin/README.md +10 -3
- package/bin/cli/_install_helpers.py +0 -3
- package/bin/cli/approvals.py +341 -238
- package/bin/cli/brief.py +45 -4
- package/bin/cli/cleanup.py +304 -4
- package/bin/cli/doctor.py +1 -5
- package/bin/cli/uninstall.py +20 -0
- package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-ops/hooks/adapters/claude_code.py +19 -85
- package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
- package/dist/gaia-ops/hooks/modules/core/plugin_setup.py +0 -5
- package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
- package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
- package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
- package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
- 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 +434 -1
- package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
- package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
- package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +177 -20
- package/dist/gaia-ops/hooks/post_compact.py +1 -0
- package/dist/gaia-ops/hooks/pre_compact.py +1 -0
- package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
- package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +27 -7
- package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +11 -6
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
- package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -28
- package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
- package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +10 -5
- package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
- package/dist/gaia-ops/skills/security-tiers/SKILL.md +1 -1
- package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +20 -6
- package/dist/gaia-ops/skills/subagent-request-approval/reference.md +23 -15
- package/dist/gaia-ops/tools/migration/README.md +10 -12
- package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
- package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
- package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
- package/dist/gaia-security/hooks/adapters/claude_code.py +19 -85
- package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
- package/dist/gaia-security/hooks/modules/core/plugin_setup.py +0 -5
- package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
- package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
- package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
- package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
- 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 +434 -1
- package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
- package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
- package/dist/gaia-security/hooks/modules/tools/bash_validator.py +177 -20
- package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
- package/gaia/approvals/store.py +87 -9
- package/gaia/briefs/__init__.py +4 -0
- package/gaia/briefs/store.py +91 -0
- package/gaia/store/schema.sql +38 -1
- package/gaia/store/writer.py +400 -0
- package/hooks/adapters/claude_code.py +19 -85
- package/hooks/elicitation_result.py +20 -75
- package/hooks/modules/context/context_injector.py +23 -7
- package/hooks/modules/core/plugin_setup.py +0 -5
- package/hooks/modules/events/event_writer.py +63 -96
- package/hooks/modules/security/__init__.py +0 -2
- package/hooks/modules/security/approval_cleanup.py +238 -69
- package/hooks/modules/security/approval_grants.py +506 -1103
- 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 +434 -1
- package/hooks/modules/session/pending_scanner.py +150 -90
- package/hooks/modules/session/session_manifest.py +257 -28
- package/hooks/modules/tools/bash_validator.py +177 -20
- package/hooks/post_compact.py +1 -0
- package/hooks/pre_compact.py +1 -0
- package/hooks/user_prompt_submit.py +20 -0
- package/package.json +1 -1
- package/pyproject.toml +20 -1
- package/scripts/bootstrap_database.sh +66 -17
- package/scripts/migrations/README.md +26 -14
- package/scripts/migrations/schema.checksum +2 -2
- package/scripts/migrations/v18_to_v19.sql +36 -0
- package/scripts/migrations/v19_to_v20.sql +20 -0
- package/skills/agent-approval-protocol/SKILL.md +27 -7
- package/skills/agent-approval-protocol/reference.md +11 -6
- package/skills/gaia-patterns/reference.md +2 -2
- package/skills/orchestrator-present-approval/SKILL.md +69 -28
- package/skills/orchestrator-present-approval/reference.md +16 -3
- package/skills/orchestrator-present-approval/template.md +10 -5
- package/skills/pending-approvals/SKILL.md +16 -11
- package/skills/security-tiers/SKILL.md +1 -1
- package/skills/subagent-request-approval/SKILL.md +20 -6
- package/skills/subagent-request-approval/reference.md +23 -15
- package/tools/migration/README.md +10 -12
- package/tools/scan/orchestrator.py +194 -10
- package/tools/scan/tests/test_integration.py +1 -2
- package/bin/cli/plans.py +0 -517
- package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
- package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
- package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
- package/dist/gaia-ops/tools/scan/merge.py +0 -213
- package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
- package/tools/context/deep_merge.py +0 -159
- package/tools/migration/migrate_04_harness_events.py +0 -132
- package/tools/migration/migrate_04_harness_events.sh +0 -23
- package/tools/scan/merge.py +0 -213
- package/tools/scan/tests/test_merge.py +0 -269
package/bin/cli/brief.py
CHANGED
|
@@ -380,16 +380,44 @@ def _cmd_edit(args) -> int:
|
|
|
380
380
|
|
|
381
381
|
|
|
382
382
|
def _cmd_show(args) -> int:
|
|
383
|
-
from gaia.briefs import
|
|
383
|
+
from gaia.briefs import (
|
|
384
|
+
get_brief,
|
|
385
|
+
get_brief_by_id,
|
|
386
|
+
find_brief_workspaces,
|
|
387
|
+
serialize_brief_to_markdown,
|
|
388
|
+
)
|
|
384
389
|
workspace = _resolve_workspace(getattr(args, "workspace", None))
|
|
385
390
|
name = args.name
|
|
391
|
+
as_json = getattr(args, "json", False)
|
|
392
|
+
|
|
393
|
+
# FIX 1: when the argument is all-digits, resolve by numeric id first.
|
|
394
|
+
if name.isdigit():
|
|
395
|
+
brief = get_brief_by_id(int(name))
|
|
396
|
+
if brief is not None:
|
|
397
|
+
if as_json:
|
|
398
|
+
out = {k: v for k, v in brief.items() if k != "id"}
|
|
399
|
+
print(json.dumps(out, indent=2, default=str))
|
|
400
|
+
return 0
|
|
401
|
+
print(serialize_brief_to_markdown(brief))
|
|
402
|
+
return 0
|
|
403
|
+
# Numeric id not found -- fall through to the slug path so that a
|
|
404
|
+
# name that happens to look like a number still gets a useful error.
|
|
386
405
|
|
|
387
406
|
brief = get_brief(workspace, name)
|
|
388
407
|
if brief is None:
|
|
389
|
-
|
|
390
|
-
|
|
408
|
+
# FIX 2: cross-workspace hint instead of bare "not found".
|
|
409
|
+
other_workspaces = find_brief_workspaces(name)
|
|
410
|
+
if other_workspaces:
|
|
411
|
+
hint = ", ".join(repr(w) for w in other_workspaces)
|
|
412
|
+
msg = (
|
|
413
|
+
f"brief '{name}' not found in workspace '{workspace}', "
|
|
414
|
+
f"but exists in: {hint} -- use --workspace=<workspace> to show it"
|
|
415
|
+
)
|
|
416
|
+
else:
|
|
417
|
+
msg = f"brief '{name}' not found in workspace '{workspace}'"
|
|
418
|
+
return _err(msg, as_json=as_json)
|
|
391
419
|
|
|
392
|
-
if
|
|
420
|
+
if as_json:
|
|
393
421
|
# Drop internal SQL columns for cleanliness
|
|
394
422
|
out = {k: v for k, v in brief.items() if k != "id"}
|
|
395
423
|
print(json.dumps(out, indent=2, default=str))
|
|
@@ -431,10 +459,23 @@ def _cmd_list(args) -> int:
|
|
|
431
459
|
|
|
432
460
|
def _cmd_close(args) -> int:
|
|
433
461
|
from gaia.briefs import close_brief
|
|
462
|
+
from gaia.briefs.store import verify_brief
|
|
434
463
|
workspace = _resolve_workspace(getattr(args, "workspace", None))
|
|
435
464
|
name = args.name
|
|
436
465
|
if close_brief(workspace, name):
|
|
437
466
|
print(f"Closed brief '{name}'")
|
|
467
|
+
# AC-3 advisory: run invariant checker and surface any inconsistencies
|
|
468
|
+
# as non-blocking stderr warnings (mirrors D11 pattern in plan CLI).
|
|
469
|
+
# Close always succeeds (exit 0); warnings never gate the operation.
|
|
470
|
+
try:
|
|
471
|
+
result = verify_brief(workspace, name)
|
|
472
|
+
for issue in result.get("inconsistencies", []):
|
|
473
|
+
print(
|
|
474
|
+
f"Warning: [{issue['kind']}] {issue['detail']}",
|
|
475
|
+
file=sys.stderr,
|
|
476
|
+
)
|
|
477
|
+
except Exception:
|
|
478
|
+
pass # advisory failure must never abort the close
|
|
438
479
|
return 0
|
|
439
480
|
return _err(f"brief '{name}' not found in workspace '{workspace}'")
|
|
440
481
|
|
package/bin/cli/cleanup.py
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
"""
|
|
2
|
-
gaia cleanup -- Remove
|
|
3
|
-
|
|
2
|
+
gaia cleanup -- Remove Gaia's workspace footprint and apply data retention.
|
|
3
|
+
|
|
4
|
+
The full-cleanup footprint mirrors what `gaia install` writes, in reverse:
|
|
5
|
+
- CLAUDE.md and .claude/settings.json (removed outright -- Gaia-owned)
|
|
6
|
+
- .claude/ symlinks incl. skills (removed -- Gaia-owned)
|
|
7
|
+
- .claude/.plugin-initialized marker (removed -- Gaia-owned)
|
|
8
|
+
- .claude/plugin-registry.json (surgical: only Gaia's installed[] entry is
|
|
9
|
+
removed; the file is shared with Claude Code's plugin system)
|
|
10
|
+
- .claude/settings.local.json (surgical: only Gaia-injected keys are removed
|
|
11
|
+
-- agent, two env vars, Gaia's permission entries; user config preserved)
|
|
12
|
+
The user DB at ~/.gaia/gaia.db is never touched here.
|
|
4
13
|
|
|
5
14
|
Modes:
|
|
6
|
-
--prune / --retain Apply data retention policy only (no
|
|
7
|
-
(default) Remove
|
|
15
|
+
--prune / --retain Apply data retention policy only (no footprint removal)
|
|
16
|
+
(default) Remove the footprint above + run retention
|
|
8
17
|
|
|
9
18
|
Flags:
|
|
10
19
|
--dry-run Print what would be pruned/removed without modifying files
|
|
@@ -16,6 +25,11 @@ import os
|
|
|
16
25
|
import sys
|
|
17
26
|
from pathlib import Path
|
|
18
27
|
|
|
28
|
+
# bin/cli/cleanup.py -> bin/cli -> bin -> gaia/
|
|
29
|
+
_PACKAGE_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
30
|
+
if str(_PACKAGE_ROOT) not in sys.path:
|
|
31
|
+
sys.path.insert(0, str(_PACKAGE_ROOT))
|
|
32
|
+
|
|
19
33
|
|
|
20
34
|
# ---------------------------------------------------------------------------
|
|
21
35
|
# Project root detection (mirrors JS findProjectRoot)
|
|
@@ -334,6 +348,7 @@ SYMLINKS_TO_REMOVE = [
|
|
|
334
348
|
".claude/hooks",
|
|
335
349
|
".claude/commands",
|
|
336
350
|
".claude/config",
|
|
351
|
+
".claude/skills",
|
|
337
352
|
".claude/CHANGELOG.md",
|
|
338
353
|
".claude/README.en.md",
|
|
339
354
|
".claude/README.md",
|
|
@@ -358,6 +373,271 @@ def _remove_settings_json(root: Path, dry_run: bool) -> dict:
|
|
|
358
373
|
return {"found": True, "removed": not dry_run, "dry_run": dry_run}
|
|
359
374
|
|
|
360
375
|
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
# Gaia-owned data-dir markers (.plugin-initialized, plugin-registry.json)
|
|
378
|
+
#
|
|
379
|
+
# Both live in get_plugin_data_dir(), which falls back to .claude/ when
|
|
380
|
+
# CLAUDE_PLUGIN_DATA is unset (the common npm-install case). The marker is a
|
|
381
|
+
# pure Gaia artifact -- removed outright. The registry is shared with Claude
|
|
382
|
+
# Code's plugin system, so only Gaia's own entry is removed surgically.
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
# Plugin names Gaia registers in plugin-registry.json (see _read_plugin_name
|
|
386
|
+
# in _install_helpers.py: package "gaia" maps to the canonical "gaia-ops").
|
|
387
|
+
_GAIA_PLUGIN_NAMES = {"gaia-ops", "gaia-security", "gaia"}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _remove_plugin_initialized(root: Path, dry_run: bool) -> dict:
|
|
391
|
+
"""Remove the .plugin-initialized first-run marker.
|
|
392
|
+
|
|
393
|
+
Written by plugin_setup.mark_initialized() into get_plugin_data_dir().
|
|
394
|
+
A pure Gaia artifact (timestamp + mode), safe to delete outright.
|
|
395
|
+
"""
|
|
396
|
+
path = root / ".claude" / ".plugin-initialized"
|
|
397
|
+
if not path.exists():
|
|
398
|
+
return {"found": False}
|
|
399
|
+
if not dry_run:
|
|
400
|
+
try:
|
|
401
|
+
path.unlink()
|
|
402
|
+
except OSError as exc:
|
|
403
|
+
return {"found": True, "removed": False, "error": str(exc)}
|
|
404
|
+
return {"found": True, "removed": not dry_run, "dry_run": dry_run}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _remove_plugin_registry_entry(root: Path, dry_run: bool) -> dict:
|
|
408
|
+
"""Remove Gaia's entry from plugin-registry.json, preserving other plugins.
|
|
409
|
+
|
|
410
|
+
plugin-registry.json is shared with Claude Code's plugin system. Install
|
|
411
|
+
(register_plugin / ensure_plugin_registry) writes Gaia's entry into the
|
|
412
|
+
``installed`` list. Uninstall is symmetric: drop only Gaia-owned entries
|
|
413
|
+
from ``installed``. If that empties the registry of all plugins, the file
|
|
414
|
+
is equivalent to its pre-Gaia state and is removed; otherwise it is
|
|
415
|
+
rewritten with the surviving entries.
|
|
416
|
+
|
|
417
|
+
Idempotent: a registry with no Gaia entry returns found=False.
|
|
418
|
+
"""
|
|
419
|
+
path = root / ".claude" / "plugin-registry.json"
|
|
420
|
+
if not path.exists():
|
|
421
|
+
return {"found": False}
|
|
422
|
+
|
|
423
|
+
data = _read_json_file(path)
|
|
424
|
+
if not isinstance(data, dict):
|
|
425
|
+
# Malformed/unexpected shape -- leave it untouched rather than risk
|
|
426
|
+
# destroying a file Gaia did not write.
|
|
427
|
+
return {"found": False, "skipped": "registry not a JSON object"}
|
|
428
|
+
|
|
429
|
+
installed = data.get("installed")
|
|
430
|
+
if not isinstance(installed, list):
|
|
431
|
+
return {"found": False, "skipped": "no installed[] array"}
|
|
432
|
+
|
|
433
|
+
kept = [
|
|
434
|
+
e for e in installed
|
|
435
|
+
if not (isinstance(e, dict) and e.get("name") in _GAIA_PLUGIN_NAMES)
|
|
436
|
+
]
|
|
437
|
+
removed_entries = [
|
|
438
|
+
e for e in installed
|
|
439
|
+
if isinstance(e, dict) and e.get("name") in _GAIA_PLUGIN_NAMES
|
|
440
|
+
]
|
|
441
|
+
|
|
442
|
+
if not removed_entries:
|
|
443
|
+
return {"found": False}
|
|
444
|
+
|
|
445
|
+
# Only Gaia keys present (installed + source) and nothing survives ->
|
|
446
|
+
# the file existed solely for Gaia; remove it entirely.
|
|
447
|
+
only_gaia_keys = set(data.keys()) <= {"installed", "source"}
|
|
448
|
+
delete_file = not kept and only_gaia_keys
|
|
449
|
+
|
|
450
|
+
result = {
|
|
451
|
+
"found": True,
|
|
452
|
+
"removed_entries": [e.get("name") for e in removed_entries],
|
|
453
|
+
"file_removed": delete_file and not dry_run,
|
|
454
|
+
"dry_run": dry_run,
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if dry_run:
|
|
458
|
+
return result
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
if delete_file:
|
|
462
|
+
path.unlink()
|
|
463
|
+
else:
|
|
464
|
+
data["installed"] = kept
|
|
465
|
+
_write_json_file(path, data)
|
|
466
|
+
except OSError as exc:
|
|
467
|
+
result["error"] = str(exc)
|
|
468
|
+
return result
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
# settings.local.json -- surgical removal of Gaia-injected config
|
|
473
|
+
#
|
|
474
|
+
# Install (merge_local_permissions) MERGES into a user-owned file: it sets
|
|
475
|
+
# agent=gaia-orchestrator, adds two env vars, and authoritative-merges its
|
|
476
|
+
# permission entries. Uninstall must mirror that injection key-for-key and
|
|
477
|
+
# leave everything the user added intact -- never delete the whole file.
|
|
478
|
+
#
|
|
479
|
+
# Note on permissions: install's authoritative merge already overwrote any
|
|
480
|
+
# user-scoped variants of tool names Gaia manages (e.g. a prior Edit(/tmp/*)
|
|
481
|
+
# became Edit). That loss is not recoverable at uninstall time; the symmetric
|
|
482
|
+
# action is to drop the tool names Gaia manages and its deny rules, leaving
|
|
483
|
+
# only entries for tools Gaia never touched.
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
_GAIA_ENV_KEYS = {
|
|
487
|
+
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1",
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _gaia_managed_permission_sets():
|
|
492
|
+
"""Return (managed_tool_names, deny_entries) Gaia injects into settings.
|
|
493
|
+
|
|
494
|
+
Pulled from the canonical OPS/SECURITY permission constants in
|
|
495
|
+
plugin_setup.py (the same source install merges from). Falls back to a
|
|
496
|
+
minimal set if the hooks package cannot be imported (partial install).
|
|
497
|
+
"""
|
|
498
|
+
try:
|
|
499
|
+
from hooks.modules.core.plugin_setup import ( # type: ignore
|
|
500
|
+
OPS_PERMISSIONS,
|
|
501
|
+
SECURITY_PERMISSIONS,
|
|
502
|
+
_tool_name,
|
|
503
|
+
)
|
|
504
|
+
allow = (
|
|
505
|
+
set(OPS_PERMISSIONS["permissions"]["allow"])
|
|
506
|
+
| set(SECURITY_PERMISSIONS["permissions"]["allow"])
|
|
507
|
+
)
|
|
508
|
+
deny = (
|
|
509
|
+
set(OPS_PERMISSIONS["permissions"]["deny"])
|
|
510
|
+
| set(SECURITY_PERMISSIONS["permissions"]["deny"])
|
|
511
|
+
)
|
|
512
|
+
managed_names = {_tool_name(e) for e in allow}
|
|
513
|
+
return managed_names, deny
|
|
514
|
+
except Exception: # noqa: BLE001
|
|
515
|
+
return {"Bash"}, set()
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _clean_settings_local_json(root: Path, dry_run: bool) -> dict:
|
|
519
|
+
"""Remove only Gaia-injected keys from settings.local.json; preserve user config.
|
|
520
|
+
|
|
521
|
+
Mirrors merge_local_permissions:
|
|
522
|
+
- agent: removed only if it equals "gaia-orchestrator".
|
|
523
|
+
- env: removes the two Gaia keys only when their value matches what
|
|
524
|
+
install set (a user override of the value is preserved).
|
|
525
|
+
- permissions.allow: drops entries whose tool name Gaia manages.
|
|
526
|
+
- permissions.deny: drops Gaia's deny rules.
|
|
527
|
+
- Empty containers (env / permissions / allow / deny) are pruned so the
|
|
528
|
+
file does not retain hollow Gaia scaffolding.
|
|
529
|
+
|
|
530
|
+
If nothing Gaia-owned remains and the file becomes ``{}``, it is removed.
|
|
531
|
+
Idempotent: a file with no Gaia keys returns found=False.
|
|
532
|
+
"""
|
|
533
|
+
path = root / ".claude" / "settings.local.json"
|
|
534
|
+
if not path.exists():
|
|
535
|
+
return {"found": False}
|
|
536
|
+
|
|
537
|
+
data = _read_json_file(path)
|
|
538
|
+
if not isinstance(data, dict):
|
|
539
|
+
return {"found": False, "skipped": "settings.local.json not a JSON object"}
|
|
540
|
+
|
|
541
|
+
removed_fields: list[str] = []
|
|
542
|
+
|
|
543
|
+
# agent identity
|
|
544
|
+
if data.get("agent") == "gaia-orchestrator":
|
|
545
|
+
removed_fields.append("agent")
|
|
546
|
+
if not dry_run:
|
|
547
|
+
del data["agent"]
|
|
548
|
+
|
|
549
|
+
# env vars (only when value still matches what install set)
|
|
550
|
+
env = data.get("env")
|
|
551
|
+
if isinstance(env, dict):
|
|
552
|
+
for key, expected in _GAIA_ENV_KEYS.items():
|
|
553
|
+
if env.get(key) == expected:
|
|
554
|
+
removed_fields.append(f"env.{key}")
|
|
555
|
+
if not dry_run:
|
|
556
|
+
del env[key]
|
|
557
|
+
if not dry_run and not env:
|
|
558
|
+
del data["env"]
|
|
559
|
+
|
|
560
|
+
# permissions
|
|
561
|
+
perms = data.get("permissions")
|
|
562
|
+
if isinstance(perms, dict):
|
|
563
|
+
managed_names, gaia_deny = _gaia_managed_permission_sets()
|
|
564
|
+
|
|
565
|
+
allow = perms.get("allow")
|
|
566
|
+
if isinstance(allow, list):
|
|
567
|
+
kept_allow = [e for e in allow if _perm_tool_name(e) not in managed_names]
|
|
568
|
+
if len(kept_allow) != len(allow):
|
|
569
|
+
removed_fields.append("permissions.allow")
|
|
570
|
+
if not dry_run:
|
|
571
|
+
if kept_allow:
|
|
572
|
+
perms["allow"] = kept_allow
|
|
573
|
+
else:
|
|
574
|
+
del perms["allow"]
|
|
575
|
+
|
|
576
|
+
deny = perms.get("deny")
|
|
577
|
+
if isinstance(deny, list):
|
|
578
|
+
kept_deny = [e for e in deny if e not in gaia_deny]
|
|
579
|
+
if len(kept_deny) != len(deny):
|
|
580
|
+
removed_fields.append("permissions.deny")
|
|
581
|
+
if not dry_run:
|
|
582
|
+
if kept_deny:
|
|
583
|
+
perms["deny"] = kept_deny
|
|
584
|
+
else:
|
|
585
|
+
del perms["deny"]
|
|
586
|
+
|
|
587
|
+
# Drop hollow empty lists (ask/allow/deny) and an empty permissions
|
|
588
|
+
# block -- these are Gaia scaffolding, not user data.
|
|
589
|
+
if not dry_run:
|
|
590
|
+
for empty_key in ("ask", "allow", "deny"):
|
|
591
|
+
if perms.get(empty_key) == []:
|
|
592
|
+
del perms[empty_key]
|
|
593
|
+
if not perms:
|
|
594
|
+
del data["permissions"]
|
|
595
|
+
|
|
596
|
+
if not removed_fields:
|
|
597
|
+
return {"found": False}
|
|
598
|
+
|
|
599
|
+
result = {
|
|
600
|
+
"found": True,
|
|
601
|
+
"removed_fields": removed_fields,
|
|
602
|
+
"file_removed": False,
|
|
603
|
+
"dry_run": dry_run,
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if dry_run:
|
|
607
|
+
return result
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
if not data:
|
|
611
|
+
path.unlink()
|
|
612
|
+
result["file_removed"] = True
|
|
613
|
+
else:
|
|
614
|
+
_write_json_file(path, data)
|
|
615
|
+
except OSError as exc:
|
|
616
|
+
result["error"] = str(exc)
|
|
617
|
+
return result
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _perm_tool_name(entry: str) -> str:
|
|
621
|
+
"""Base tool name from a permission entry (mirror of plugin_setup._tool_name)."""
|
|
622
|
+
if not isinstance(entry, str):
|
|
623
|
+
return ""
|
|
624
|
+
paren = entry.find("(")
|
|
625
|
+
return entry[:paren] if paren != -1 else entry
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _read_json_file(path: Path):
|
|
629
|
+
"""Read JSON, returning None on any error."""
|
|
630
|
+
try:
|
|
631
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
632
|
+
except (OSError, json.JSONDecodeError):
|
|
633
|
+
return None
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _write_json_file(path: Path, data) -> None:
|
|
637
|
+
"""Write JSON with indent=2 + trailing newline (gaia convention)."""
|
|
638
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
639
|
+
|
|
640
|
+
|
|
361
641
|
def _remove_symlinks(root: Path, dry_run: bool) -> dict:
|
|
362
642
|
removed = []
|
|
363
643
|
skipped = []
|
|
@@ -513,11 +793,17 @@ def cmd_cleanup(args) -> int:
|
|
|
513
793
|
|
|
514
794
|
claude_md = _remove_claude_md(root, dry_run)
|
|
515
795
|
settings = _remove_settings_json(root, dry_run)
|
|
796
|
+
settings_local = _clean_settings_local_json(root, dry_run)
|
|
797
|
+
plugin_initialized = _remove_plugin_initialized(root, dry_run)
|
|
798
|
+
plugin_registry = _remove_plugin_registry_entry(root, dry_run)
|
|
516
799
|
symlinks = _remove_symlinks(root, dry_run)
|
|
517
800
|
retention_actions = _apply_retention_policy(root, dry_run)
|
|
518
801
|
|
|
519
802
|
result["claude_md"] = claude_md
|
|
520
803
|
result["settings_json"] = settings
|
|
804
|
+
result["settings_local_json"] = settings_local
|
|
805
|
+
result["plugin_initialized"] = plugin_initialized
|
|
806
|
+
result["plugin_registry"] = plugin_registry
|
|
521
807
|
result["symlinks"] = symlinks
|
|
522
808
|
result["retention_actions"] = retention_actions
|
|
523
809
|
result["retention_policy"] = retention_policy_info
|
|
@@ -529,6 +815,9 @@ def cmd_cleanup(args) -> int:
|
|
|
529
815
|
anything_done = (
|
|
530
816
|
claude_md.get("found")
|
|
531
817
|
or settings.get("found")
|
|
818
|
+
or settings_local.get("found")
|
|
819
|
+
or plugin_initialized.get("found")
|
|
820
|
+
or plugin_registry.get("found")
|
|
532
821
|
or symlinks.get("removed")
|
|
533
822
|
or retention_actions
|
|
534
823
|
)
|
|
@@ -539,6 +828,17 @@ def cmd_cleanup(args) -> int:
|
|
|
539
828
|
if settings.get("found"):
|
|
540
829
|
verb = "Would remove" if dry_run else "Removed"
|
|
541
830
|
print(f" {verb}: .claude/settings.json")
|
|
831
|
+
if settings_local.get("found"):
|
|
832
|
+
verb = "Would clean" if dry_run else "Cleaned"
|
|
833
|
+
fields = ", ".join(settings_local.get("removed_fields", []))
|
|
834
|
+
print(f" {verb}: .claude/settings.local.json ({fields})")
|
|
835
|
+
if plugin_initialized.get("found"):
|
|
836
|
+
verb = "Would remove" if dry_run else "Removed"
|
|
837
|
+
print(f" {verb}: .claude/.plugin-initialized")
|
|
838
|
+
if plugin_registry.get("found"):
|
|
839
|
+
verb = "Would remove" if dry_run else "Removed"
|
|
840
|
+
entries = ", ".join(plugin_registry.get("removed_entries", []))
|
|
841
|
+
print(f" {verb}: plugin-registry.json entry ({entries})")
|
|
542
842
|
for rel in symlinks.get("removed", []):
|
|
543
843
|
verb = "Would remove symlink" if dry_run else "Removed symlink"
|
|
544
844
|
print(f" {verb}: {rel}")
|
package/bin/cli/doctor.py
CHANGED
|
@@ -185,7 +185,7 @@ def _package_root() -> Path:
|
|
|
185
185
|
# in lock-step with the INSERT it adds to bootstrap_database.sh. If a user
|
|
186
186
|
# upgrades the CLI past a schema bump but does not re-run `gaia install`,
|
|
187
187
|
# `check_schema_version` raises a warning telling them how to repair.
|
|
188
|
-
EXPECTED_SCHEMA_VERSION =
|
|
188
|
+
EXPECTED_SCHEMA_VERSION = 20
|
|
189
189
|
|
|
190
190
|
# Locations the doctor reads outside the workspace.
|
|
191
191
|
_INSTALL_ERROR_MARKER = Path("~/.gaia/last-install-error.json").expanduser()
|
|
@@ -854,10 +854,6 @@ def check_settings(project_root: Path) -> dict:
|
|
|
854
854
|
if deny_count == 0:
|
|
855
855
|
issues.append("No deny rules (destructive commands not blocked)")
|
|
856
856
|
|
|
857
|
-
env = data.get("env", {})
|
|
858
|
-
if not env.get("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"):
|
|
859
|
-
infos.append("AGENT_TEAMS env not set")
|
|
860
|
-
|
|
861
857
|
if issues:
|
|
862
858
|
return _result("Settings", "error", "; ".join(issues), "Run `gaia scan` or `gaia update`")
|
|
863
859
|
|
package/bin/cli/uninstall.py
CHANGED
|
@@ -38,8 +38,11 @@ from pathlib import Path
|
|
|
38
38
|
# duplicating retention policy, symlink lists, or root detection.
|
|
39
39
|
from cli.cleanup import ( # type: ignore # noqa: E402
|
|
40
40
|
_apply_retention_policy,
|
|
41
|
+
_clean_settings_local_json,
|
|
41
42
|
_find_project_root,
|
|
42
43
|
_remove_claude_md,
|
|
44
|
+
_remove_plugin_initialized,
|
|
45
|
+
_remove_plugin_registry_entry,
|
|
43
46
|
_remove_settings_json,
|
|
44
47
|
_remove_symlinks,
|
|
45
48
|
)
|
|
@@ -250,6 +253,9 @@ def cmd_uninstall(args: argparse.Namespace) -> int:
|
|
|
250
253
|
try:
|
|
251
254
|
result["claude_md"] = _remove_claude_md(workspace, dry_run)
|
|
252
255
|
result["settings_json"] = _remove_settings_json(workspace, dry_run)
|
|
256
|
+
result["settings_local_json"] = _clean_settings_local_json(workspace, dry_run)
|
|
257
|
+
result["plugin_initialized"] = _remove_plugin_initialized(workspace, dry_run)
|
|
258
|
+
result["plugin_registry"] = _remove_plugin_registry_entry(workspace, dry_run)
|
|
253
259
|
result["symlinks"] = _remove_symlinks(workspace, dry_run)
|
|
254
260
|
result["retention_actions"] = _apply_retention_policy(workspace, dry_run)
|
|
255
261
|
except Exception as exc: # noqa: BLE001
|
|
@@ -345,6 +351,9 @@ def _print_human(result: dict, *, preuninstall: bool, purge: bool, dry_run: bool
|
|
|
345
351
|
|
|
346
352
|
claude_md = result.get("claude_md") or {}
|
|
347
353
|
settings = result.get("settings_json") or {}
|
|
354
|
+
settings_local = result.get("settings_local_json") or {}
|
|
355
|
+
plugin_initialized = result.get("plugin_initialized") or {}
|
|
356
|
+
plugin_registry = result.get("plugin_registry") or {}
|
|
348
357
|
symlinks = result.get("symlinks") or {}
|
|
349
358
|
retention = result.get("retention_actions") or []
|
|
350
359
|
db = result.get("db") or {}
|
|
@@ -355,6 +364,17 @@ def _print_human(result: dict, *, preuninstall: bool, purge: bool, dry_run: bool
|
|
|
355
364
|
if settings.get("found"):
|
|
356
365
|
verb = "Would remove" if dry_run else "Removed"
|
|
357
366
|
print(f" {verb}: .claude/settings.json")
|
|
367
|
+
if settings_local.get("found"):
|
|
368
|
+
verb = "Would clean" if dry_run else "Cleaned"
|
|
369
|
+
fields = ", ".join(settings_local.get("removed_fields", []))
|
|
370
|
+
print(f" {verb}: .claude/settings.local.json ({fields})")
|
|
371
|
+
if plugin_initialized.get("found"):
|
|
372
|
+
verb = "Would remove" if dry_run else "Removed"
|
|
373
|
+
print(f" {verb}: .claude/.plugin-initialized")
|
|
374
|
+
if plugin_registry.get("found"):
|
|
375
|
+
verb = "Would remove" if dry_run else "Removed"
|
|
376
|
+
entries = ", ".join(plugin_registry.get("removed_entries", []))
|
|
377
|
+
print(f" {verb}: plugin-registry.json entry ({entries})")
|
|
358
378
|
for rel in symlinks.get("removed", []):
|
|
359
379
|
verb = "Would remove symlink" if dry_run else "Removed symlink"
|
|
360
380
|
print(f" {verb}: {rel}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gaia-ops",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.10",
|
|
4
4
|
"description": "Full DevOps orchestration for Claude Code. Eight specialized agents handle the complete development lifecycle \u2014 analysis, planning, execution, and deployment. Gaia-Ops scans your codebase to understand it and injects the right context into each sub-agent. Every command is classified by risk: read-only runs freely, state changes pause for your approval, and irreversible operations are permanently blocked.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "jaguilar87",
|
|
@@ -1117,19 +1117,15 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
1117
1117
|
2. Load the specific pending file by prefix (any session).
|
|
1118
1118
|
3. Activate the grant under the CURRENT session.
|
|
1119
1119
|
|
|
1120
|
-
|
|
1121
|
-
the
|
|
1120
|
+
DB-only since the grant-lifecycle FS retirement: REQUESTED writes go
|
|
1121
|
+
to the DB, so the approved pending is resolved by nonce prefix straight
|
|
1122
|
+
from the DB via ``activate_db_pending_by_prefix()``.
|
|
1122
1123
|
|
|
1123
1124
|
Never blocks (no exceptions raised to caller).
|
|
1124
1125
|
"""
|
|
1125
1126
|
from modules.security.approval_grants import (
|
|
1126
|
-
activate_cross_session_pending,
|
|
1127
1127
|
activate_db_pending_by_prefix,
|
|
1128
|
-
activate_grants_for_session,
|
|
1129
|
-
activate_pending_approval,
|
|
1130
1128
|
extract_nonce_from_label,
|
|
1131
|
-
get_pending_approvals_for_session,
|
|
1132
|
-
load_pending_by_nonce_prefix,
|
|
1133
1129
|
)
|
|
1134
1130
|
|
|
1135
1131
|
session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_SESSION_ID", "")
|
|
@@ -1163,93 +1159,31 @@ class ClaudeCodeAdapter(HookAdapter):
|
|
|
1163
1159
|
logger.info("AskUserQuestion: no session_id available, skipping grant activation")
|
|
1164
1160
|
return
|
|
1165
1161
|
|
|
1166
|
-
#
|
|
1162
|
+
# Nonce-targeted activation: extract the nonce from answer labels.
|
|
1167
1163
|
nonce_prefix = None
|
|
1168
1164
|
for v in answers.values():
|
|
1169
1165
|
nonce_prefix = extract_nonce_from_label(str(v))
|
|
1170
1166
|
if nonce_prefix:
|
|
1171
1167
|
break
|
|
1172
1168
|
|
|
1173
|
-
if nonce_prefix:
|
|
1174
|
-
# Nonce-targeted: load this specific pending regardless of session
|
|
1175
|
-
pending_data = load_pending_by_nonce_prefix(nonce_prefix)
|
|
1176
|
-
if pending_data:
|
|
1177
|
-
pending_session = pending_data.get("session_id", "")
|
|
1178
|
-
full_nonce = pending_data.get("nonce", "")
|
|
1179
|
-
|
|
1180
|
-
if pending_session == session_id:
|
|
1181
|
-
# Same session -- use standard activation
|
|
1182
|
-
result = activate_pending_approval(
|
|
1183
|
-
nonce=full_nonce,
|
|
1184
|
-
session_id=session_id,
|
|
1185
|
-
)
|
|
1186
|
-
else:
|
|
1187
|
-
# Cross session -- activate under current session
|
|
1188
|
-
result = activate_cross_session_pending(
|
|
1189
|
-
pending_data,
|
|
1190
|
-
session_id=session_id,
|
|
1191
|
-
)
|
|
1192
|
-
|
|
1193
|
-
if result.success:
|
|
1194
|
-
logger.info(
|
|
1195
|
-
"AskUserQuestion nonce-targeted activation: prefix=%s, "
|
|
1196
|
-
"pending_session=%s, current_session=%s, status=%s",
|
|
1197
|
-
nonce_prefix, pending_session[:12], session_id[:12],
|
|
1198
|
-
getattr(result.status, "value", str(result.status)),
|
|
1199
|
-
)
|
|
1200
|
-
return
|
|
1201
|
-
else:
|
|
1202
|
-
logger.warning(
|
|
1203
|
-
"AskUserQuestion nonce-targeted activation failed: "
|
|
1204
|
-
"prefix=%s, status=%s, reason=%s",
|
|
1205
|
-
nonce_prefix,
|
|
1206
|
-
getattr(result.status, "value", str(result.status)),
|
|
1207
|
-
result.reason,
|
|
1208
|
-
)
|
|
1209
|
-
else:
|
|
1210
|
-
# Filesystem pending not found -- try DB lookup (M2 bridge).
|
|
1211
|
-
# Since M2, REQUESTED writes go to DB only; no pending-{nonce}.json
|
|
1212
|
-
# is written to the filesystem any more.
|
|
1213
|
-
logger.info(
|
|
1214
|
-
"AskUserQuestion: nonce prefix %s found in label but no "
|
|
1215
|
-
"matching pending file -- trying DB lookup (M2 bridge)",
|
|
1216
|
-
nonce_prefix,
|
|
1217
|
-
)
|
|
1218
|
-
result = activate_db_pending_by_prefix(
|
|
1219
|
-
nonce_prefix, current_session_id=session_id,
|
|
1220
|
-
)
|
|
1221
|
-
if result.success:
|
|
1222
|
-
logger.info(
|
|
1223
|
-
"AskUserQuestion DB-bridge activation: prefix=%s status=%s",
|
|
1224
|
-
nonce_prefix,
|
|
1225
|
-
getattr(result.status, "value", str(result.status)),
|
|
1226
|
-
)
|
|
1227
|
-
return
|
|
1228
|
-
else:
|
|
1229
|
-
logger.warning(
|
|
1230
|
-
"AskUserQuestion DB-bridge activation failed: "
|
|
1231
|
-
"prefix=%s status=%s reason=%s -- falling back to session-wide",
|
|
1232
|
-
nonce_prefix,
|
|
1233
|
-
getattr(result.status, "value", str(result.status)),
|
|
1234
|
-
result.reason,
|
|
1235
|
-
)
|
|
1236
|
-
# Fall through to session-wide activation below
|
|
1237
|
-
nonce_prefix = None
|
|
1238
|
-
|
|
1239
1169
|
if not nonce_prefix:
|
|
1240
|
-
# No nonce in label (or all targeted paths failed) -- fall back to
|
|
1241
|
-
# session-wide activation for backward compatibility
|
|
1242
|
-
pending = get_pending_approvals_for_session(session_id)
|
|
1243
|
-
if not pending:
|
|
1244
|
-
logger.info("AskUserQuestion: no pending grants for session %s", session_id)
|
|
1245
|
-
return
|
|
1246
|
-
|
|
1247
|
-
results = activate_grants_for_session(session_id)
|
|
1248
|
-
activated = sum(1 for r in results if r.success)
|
|
1249
1170
|
logger.info(
|
|
1250
|
-
"AskUserQuestion
|
|
1251
|
-
|
|
1171
|
+
"AskUserQuestion: no nonce prefix in answer labels -- "
|
|
1172
|
+
"nothing to activate for session %s", session_id[:12],
|
|
1252
1173
|
)
|
|
1174
|
+
return
|
|
1175
|
+
|
|
1176
|
+
# Resolve the approved pending straight from the DB.
|
|
1177
|
+
result = activate_db_pending_by_prefix(
|
|
1178
|
+
nonce_prefix, current_session_id=session_id,
|
|
1179
|
+
)
|
|
1180
|
+
logger.info(
|
|
1181
|
+
"AskUserQuestion DB activation: prefix=%s success=%s status=%s reason=%s",
|
|
1182
|
+
nonce_prefix,
|
|
1183
|
+
result.success,
|
|
1184
|
+
getattr(result.status, "value", str(result.status)),
|
|
1185
|
+
result.reason,
|
|
1186
|
+
)
|
|
1253
1187
|
|
|
1254
1188
|
except Exception as e:
|
|
1255
1189
|
logger.error("Error in _handle_ask_user_question_result: %s", e, exc_info=True)
|