@jaguilar87/gaia 5.0.8 → 5.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/bin/README.md +6 -1
- package/bin/cli/approvals.py +341 -238
- package/bin/cli/brief.py +13 -0
- package/bin/cli/doctor.py +1 -1
- 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/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/mutative_verbs.py +24 -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/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/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/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/mutative_verbs.py +24 -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/user_prompt_submit.py +20 -0
- package/gaia/approvals/store.py +87 -9
- 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/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/mutative_verbs.py +24 -1
- package/hooks/modules/session/pending_scanner.py +150 -90
- package/hooks/modules/session/session_manifest.py +257 -28
- 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 +1 -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/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
|
@@ -11,23 +11,26 @@ Pipeline:
|
|
|
11
11
|
3. Collect and combine scanner sections (handling environment sub-keys)
|
|
12
12
|
4. Cross-populate derived fields
|
|
13
13
|
5. Return ScanOutput
|
|
14
|
+
|
|
15
|
+
Section ownership rules (inlined from the retired tools/scan/merge.py):
|
|
16
|
+
Rule 1: Scanner-owned sections -> full replace
|
|
17
|
+
Rule 2: Agent-enriched sections -> never touch
|
|
18
|
+
Rule 3: Mixed sections -> selective update at sub-key level
|
|
19
|
+
Rule 4: Unknown/user-custom sections -> preserve
|
|
20
|
+
Rule 5: Metadata -> always update
|
|
14
21
|
"""
|
|
15
22
|
|
|
23
|
+
import copy
|
|
16
24
|
import logging
|
|
17
25
|
import time
|
|
18
26
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
19
27
|
from dataclasses import dataclass, field
|
|
20
28
|
from datetime import datetime, timezone
|
|
21
29
|
from pathlib import Path
|
|
22
|
-
from typing import Any, Dict, List, Optional
|
|
30
|
+
from typing import Any, Dict, List, Optional, Set
|
|
23
31
|
|
|
24
32
|
from tools.scan import __version__ as scanner_package_version
|
|
25
33
|
from tools.scan.config import ScanConfig
|
|
26
|
-
from tools.scan.merge import (
|
|
27
|
-
AGENT_ENRICHED_SECTIONS,
|
|
28
|
-
collect_scanner_sections,
|
|
29
|
-
merge_context,
|
|
30
|
-
)
|
|
31
34
|
from tools.scan.registry import ScannerRegistry
|
|
32
35
|
from tools.scan.scanners.base import BaseScanner, ScanResult
|
|
33
36
|
from tools.scan.workspace import WorkspaceInfo, detect_workspace_type
|
|
@@ -35,6 +38,190 @@ from tools.scan.workspace import WorkspaceInfo, detect_workspace_type
|
|
|
35
38
|
logger = logging.getLogger(__name__)
|
|
36
39
|
|
|
37
40
|
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Section ownership constants (Rule 1 / Rule 2 / Rule 3)
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
# Sections fully owned by scanners -- replaced entirely on each scan (Rule 1)
|
|
46
|
+
# Top-level sections only; sub-key ownership handled separately
|
|
47
|
+
SCANNER_OWNED_TOP_LEVEL: Dict[str, str] = {
|
|
48
|
+
"project_identity": "stack",
|
|
49
|
+
"stack": "stack",
|
|
50
|
+
"git": "git",
|
|
51
|
+
"infrastructure": "infrastructure",
|
|
52
|
+
"orchestration": "orchestration",
|
|
53
|
+
# "environment" is NOT listed here because it has sub-key ownership
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Sub-key ownership within the `environment` section (Rule 4 / sub-section)
|
|
57
|
+
# Maps environment sub-key -> owning scanner name
|
|
58
|
+
ENVIRONMENT_SUBKEY_OWNERS: Dict[str, str] = {
|
|
59
|
+
"tools": "tools",
|
|
60
|
+
"tool_preferences": "tools",
|
|
61
|
+
"os": "environment",
|
|
62
|
+
"runtimes": "environment",
|
|
63
|
+
"env_files": "environment",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Agent-enriched sections -- never modified by scanners (Rule 2)
|
|
67
|
+
AGENT_ENRICHED_SECTIONS: frozenset = frozenset([
|
|
68
|
+
"operational_guidelines",
|
|
69
|
+
"cluster_details",
|
|
70
|
+
"infrastructure_topology",
|
|
71
|
+
"monitoring_observability",
|
|
72
|
+
"architecture_overview",
|
|
73
|
+
"gcp_services",
|
|
74
|
+
"workload_identity",
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
# Mixed sections with partial scanner ownership (Rule 3)
|
|
78
|
+
# Maps section_name -> set of scanner-owned field names
|
|
79
|
+
MIXED_SECTION_SCANNER_FIELDS: Dict[str, Set[str]] = {
|
|
80
|
+
"terraform_infrastructure": {"layout"},
|
|
81
|
+
"gitops_configuration": {"repository"},
|
|
82
|
+
"application_services": {"base_path", "services"},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Section collection and merge helpers
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def collect_scanner_sections(
|
|
91
|
+
scanner_results: Dict[str, Any],
|
|
92
|
+
) -> Dict[str, Any]:
|
|
93
|
+
"""Collect and combine sections from all scanner results.
|
|
94
|
+
|
|
95
|
+
Handles the environment section specially: both `tools` and `environment`
|
|
96
|
+
scanners produce sub-keys under `environment`, so their outputs are
|
|
97
|
+
combined into a single `environment` section.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
scanner_results: Mapping of scanner_name -> ScanResult (must have
|
|
101
|
+
a `sections` attribute that is a dict).
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Combined sections dict from all scanners.
|
|
105
|
+
"""
|
|
106
|
+
combined: Dict[str, Any] = {}
|
|
107
|
+
environment_parts: Dict[str, Any] = {}
|
|
108
|
+
|
|
109
|
+
for _scanner_name, scan_result in scanner_results.items():
|
|
110
|
+
sections = scan_result.sections if hasattr(scan_result, "sections") else {}
|
|
111
|
+
|
|
112
|
+
for section_name, section_data in sections.items():
|
|
113
|
+
if section_name == "environment":
|
|
114
|
+
# Merge environment sub-keys from both scanners
|
|
115
|
+
if isinstance(section_data, dict):
|
|
116
|
+
for key, value in section_data.items():
|
|
117
|
+
if key != "_source":
|
|
118
|
+
environment_parts[key] = value
|
|
119
|
+
else:
|
|
120
|
+
# Non-environment sections: direct assignment (last scanner wins,
|
|
121
|
+
# but each section should have exactly one owner)
|
|
122
|
+
combined[section_name] = section_data
|
|
123
|
+
|
|
124
|
+
# Reassemble environment section if we got any parts
|
|
125
|
+
if environment_parts:
|
|
126
|
+
combined["environment"] = {
|
|
127
|
+
"_source": "scanner:environment+tools",
|
|
128
|
+
**environment_parts,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return combined
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _merge_environment_section(
|
|
135
|
+
result: Dict[str, Any],
|
|
136
|
+
scan_sections: Dict[str, Any],
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Merge the `environment` section with sub-key level ownership.
|
|
139
|
+
|
|
140
|
+
Two scanners contribute to the `environment` section:
|
|
141
|
+
- `tools` scanner owns: tools, tool_preferences
|
|
142
|
+
- `environment` scanner owns: os, runtimes, env_files
|
|
143
|
+
|
|
144
|
+
Each scanner's sub-keys replace their owned portion; the other scanner's
|
|
145
|
+
sub-keys are preserved. The `_source` field gets a combined tag.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
result: The result dict being built (mutated in place).
|
|
149
|
+
scan_sections: Combined sections from all scanners.
|
|
150
|
+
"""
|
|
151
|
+
if "environment" not in scan_sections:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
scan_env = scan_sections["environment"]
|
|
155
|
+
|
|
156
|
+
if "environment" not in result:
|
|
157
|
+
result["environment"] = {}
|
|
158
|
+
|
|
159
|
+
env = result["environment"]
|
|
160
|
+
|
|
161
|
+
# Replace each sub-key based on ownership
|
|
162
|
+
for subkey in ENVIRONMENT_SUBKEY_OWNERS:
|
|
163
|
+
if subkey in scan_env:
|
|
164
|
+
env[subkey] = copy.deepcopy(scan_env[subkey])
|
|
165
|
+
|
|
166
|
+
# Set combined _source tag
|
|
167
|
+
env["_source"] = "scanner:environment+tools"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _merge_sections(
|
|
171
|
+
existing: Dict[str, Any],
|
|
172
|
+
scan_sections: Dict[str, Any],
|
|
173
|
+
) -> Dict[str, Any]:
|
|
174
|
+
"""Merge scanner results with existing project-context sections.
|
|
175
|
+
|
|
176
|
+
Applies the ownership rules to produce the final merged sections dict.
|
|
177
|
+
Called with existing={} in normal scan runs (display-only path).
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
existing: Current sections (may be empty when called from scan).
|
|
181
|
+
scan_sections: Combined sections from all scanners.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Merged sections dict. The merge is deterministic: same inputs always
|
|
185
|
+
produce the same output.
|
|
186
|
+
"""
|
|
187
|
+
result = copy.deepcopy(existing)
|
|
188
|
+
|
|
189
|
+
# --- Rule 1: Scanner-owned top-level sections -> full replace ---
|
|
190
|
+
for section_name in SCANNER_OWNED_TOP_LEVEL:
|
|
191
|
+
if section_name in scan_sections:
|
|
192
|
+
result[section_name] = copy.deepcopy(scan_sections[section_name])
|
|
193
|
+
|
|
194
|
+
# --- Sub-section level ownership for `environment` ---
|
|
195
|
+
_merge_environment_section(result, scan_sections)
|
|
196
|
+
|
|
197
|
+
# --- Rule 2: Agent-enriched sections -> never touch ---
|
|
198
|
+
# These are already in `result` from the deepcopy of `existing`.
|
|
199
|
+
# (No action needed -- they are preserved by the deepcopy.)
|
|
200
|
+
|
|
201
|
+
# --- Rule 3: Mixed sections -> selective update ---
|
|
202
|
+
for section_name, scanner_fields in MIXED_SECTION_SCANNER_FIELDS.items():
|
|
203
|
+
if section_name in scan_sections:
|
|
204
|
+
scan_data = scan_sections[section_name]
|
|
205
|
+
if section_name not in result:
|
|
206
|
+
result[section_name] = {}
|
|
207
|
+
# Only update scanner-owned fields; preserve agent fields
|
|
208
|
+
for field_name in scanner_fields:
|
|
209
|
+
if field_name in scan_data:
|
|
210
|
+
result[section_name][field_name] = copy.deepcopy(
|
|
211
|
+
scan_data[field_name]
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# --- Rule 5: Unknown/user-custom sections -> preserve ---
|
|
215
|
+
# Any section in `existing` not covered above is preserved by the deepcopy.
|
|
216
|
+
# We do NOT add new unknown sections from scan_sections.
|
|
217
|
+
|
|
218
|
+
return result
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# ScanOutput dataclass
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
38
225
|
@dataclass(frozen=True)
|
|
39
226
|
class ScanOutput:
|
|
40
227
|
"""Aggregated output from all scanners.
|
|
@@ -187,11 +374,9 @@ class ScanOrchestrator:
|
|
|
187
374
|
scan_sections = collect_scanner_sections(scanner_results)
|
|
188
375
|
|
|
189
376
|
# Merge with empty existing context (no JSON persistence)
|
|
190
|
-
|
|
191
|
-
merged_sections = merge_context(
|
|
377
|
+
merged_sections = _merge_sections(
|
|
192
378
|
existing={},
|
|
193
379
|
scan_sections=scan_sections,
|
|
194
|
-
section_owners=section_owners,
|
|
195
380
|
)
|
|
196
381
|
|
|
197
382
|
# Determine which sections were updated vs preserved
|
|
@@ -210,7 +395,6 @@ class ScanOrchestrator:
|
|
|
210
395
|
self._cross_populate_monorepo(merged_sections)
|
|
211
396
|
|
|
212
397
|
# Remove empty {} placeholders for agent-enriched and mixed sections
|
|
213
|
-
from tools.scan.merge import MIXED_SECTION_SCANNER_FIELDS
|
|
214
398
|
remove_if_empty = (
|
|
215
399
|
AGENT_ENRICHED_SECTIONS
|
|
216
400
|
| frozenset(MIXED_SECTION_SCANNER_FIELDS.keys())
|
|
@@ -23,8 +23,7 @@ from unittest.mock import patch
|
|
|
23
23
|
import pytest
|
|
24
24
|
|
|
25
25
|
from tools.scan.config import ScanConfig
|
|
26
|
-
from tools.scan.
|
|
27
|
-
from tools.scan.orchestrator import ScanOrchestrator, ScanOutput
|
|
26
|
+
from tools.scan.orchestrator import AGENT_ENRICHED_SECTIONS, ScanOrchestrator, ScanOutput
|
|
28
27
|
from tools.scan.registry import ScannerRegistry
|
|
29
28
|
from tools.scan.scanners.base import BaseScanner, ScanResult
|
|
30
29
|
from tools.scan.tests.conftest import create_git_dir
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gaia-security",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.9",
|
|
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",
|
|
@@ -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)
|
|
@@ -450,17 +450,33 @@ def build_project_context(
|
|
|
450
450
|
if critical_summary:
|
|
451
451
|
context_string += critical_summary
|
|
452
452
|
|
|
453
|
-
# Inject recent operational events (non-blocking)
|
|
453
|
+
# Inject recent operational events (non-blocking).
|
|
454
|
+
# Brief 54 / Task 2.2: read from the harness_events DB table via
|
|
455
|
+
# gaia.store.reader.cross_surface_query instead of the legacy
|
|
456
|
+
# events.jsonl reader. The reader returns rows shaped as
|
|
457
|
+
# {surface, timestamp, type, agent, summary, raw} -- NOT the old
|
|
458
|
+
# {ts, type, agent, result} JSONL shape -- so the formatting loop
|
|
459
|
+
# below is remapped to those keys (audit Risk 4: without the remap
|
|
460
|
+
# the "Recent Events" block silently goes blank).
|
|
454
461
|
try:
|
|
455
|
-
|
|
456
|
-
|
|
462
|
+
import sys as _sys
|
|
463
|
+
from pathlib import Path as _Path
|
|
464
|
+
try:
|
|
465
|
+
from gaia.store import reader as _reader
|
|
466
|
+
except ImportError:
|
|
467
|
+
_repo_root = _Path(__file__).resolve().parents[3]
|
|
468
|
+
_sys.path.insert(0, str(_repo_root))
|
|
469
|
+
from gaia.store import reader as _reader
|
|
470
|
+
recent = _reader.cross_surface_query(
|
|
471
|
+
surface="harness_events", since="24h", last=20,
|
|
472
|
+
)
|
|
457
473
|
if recent:
|
|
458
474
|
lines = ["\n# Recent Events (last 24h)"]
|
|
459
475
|
for evt in recent:
|
|
460
|
-
ts_short = evt.get("
|
|
461
|
-
etype = evt.get("type"
|
|
462
|
-
agent_name = evt.get("agent"
|
|
463
|
-
result_str = evt.get("
|
|
476
|
+
ts_short = (evt.get("timestamp") or "")[:16]
|
|
477
|
+
etype = evt.get("type") or ""
|
|
478
|
+
agent_name = evt.get("agent") or ""
|
|
479
|
+
result_str = evt.get("summary") or ""
|
|
464
480
|
label = f"{agent_name}: " if agent_name else ""
|
|
465
481
|
lines.append(f"- [{ts_short}] {etype}: {label}{result_str}")
|
|
466
482
|
context_string += "\n".join(lines) + "\n"
|
|
@@ -1,16 +1,27 @@
|
|
|
1
|
-
"""Event writer
|
|
1
|
+
"""Event writer for the GAIA Event Context system.
|
|
2
|
+
|
|
3
|
+
As of Brief 54 / Task 2.2 the event pipeline writes to the ``harness_events``
|
|
4
|
+
table in the Gaia SQLite substrate (``~/.gaia/gaia.db``) instead of the legacy
|
|
5
|
+
``events.jsonl`` file. This is an ATOMIC cutover: ``write_event`` no longer
|
|
6
|
+
touches ``events.jsonl`` in any code path -- there is NO dual-write.
|
|
2
7
|
|
|
3
8
|
Provides:
|
|
4
|
-
- EventWriter:
|
|
5
|
-
- read_events():
|
|
6
|
-
|
|
9
|
+
- EventWriter: non-blocking, silent-on-failure DB event writer
|
|
10
|
+
- read_events(): legacy JSONL reader (read-only; retained until Task 2.3
|
|
11
|
+
removes events.jsonl entirely -- no longer the canonical read path)
|
|
7
12
|
- Event type constants
|
|
13
|
+
|
|
14
|
+
The DB write delegates to ``gaia.store.writer.write_harness_event``, which
|
|
15
|
+
resolves the DB path the same way every other gaia DB writer does (via
|
|
16
|
+
``gaia.paths.db_path()`` -> ``GAIA_DATA_DIR`` / ``gaia.db``, falling back to
|
|
17
|
+
``~/.gaia/gaia.db``). The hook subprocess imports the ``gaia`` package via the
|
|
18
|
+
repo-root fallback already established by handoff_persister.
|
|
8
19
|
"""
|
|
9
20
|
|
|
10
|
-
import fcntl
|
|
11
21
|
import json
|
|
12
22
|
import logging
|
|
13
23
|
import os
|
|
24
|
+
import sys
|
|
14
25
|
from datetime import datetime, timedelta, timezone
|
|
15
26
|
from pathlib import Path
|
|
16
27
|
from typing import Any, Dict, List, Optional
|
|
@@ -32,17 +43,36 @@ HEARTBEAT = "heartbeat"
|
|
|
32
43
|
USER_NOTE = "user.note"
|
|
33
44
|
|
|
34
45
|
|
|
46
|
+
def _import_store_writer():
|
|
47
|
+
"""Import gaia.store.writer, falling back to the repo layout.
|
|
48
|
+
|
|
49
|
+
Mirrors the import contract used by
|
|
50
|
+
hooks/modules/agents/handoff_persister.py: prefer a sibling ``gaia``
|
|
51
|
+
package if installed; otherwise add the repo root (two levels above
|
|
52
|
+
``hooks/``) to ``sys.path`` and import from there.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
from gaia.store import writer as _writer
|
|
56
|
+
except ImportError:
|
|
57
|
+
_repo_root = Path(__file__).resolve().parents[3]
|
|
58
|
+
sys.path.insert(0, str(_repo_root))
|
|
59
|
+
from gaia.store import writer as _writer
|
|
60
|
+
return _writer
|
|
61
|
+
|
|
62
|
+
|
|
35
63
|
class EventWriter:
|
|
36
|
-
"""
|
|
64
|
+
"""Non-blocking DB event writer.
|
|
37
65
|
|
|
38
|
-
All writes are wrapped in try/except -- events are non-critical and
|
|
39
|
-
|
|
66
|
+
All writes are wrapped in try/except -- events are non-critical and must
|
|
67
|
+
never block the hook pipeline. The ``events_dir`` argument is retained for
|
|
68
|
+
backward compatibility (legacy JSONL reads still resolve it) but is no
|
|
69
|
+
longer used for writes, which target the ``harness_events`` DB table.
|
|
40
70
|
"""
|
|
41
71
|
|
|
42
72
|
def __init__(self, events_dir: Optional[Path] = None):
|
|
73
|
+
# Retained for compatibility with the legacy reader; not used for
|
|
74
|
+
# writes. Resolved lazily-safe (never raises here).
|
|
43
75
|
self.events_dir = events_dir or get_events_dir()
|
|
44
|
-
self.events_file = self.events_dir / "events.jsonl"
|
|
45
|
-
self.lock_file = self.events_dir / "events.jsonl.lock"
|
|
46
76
|
|
|
47
77
|
def write_event(
|
|
48
78
|
self,
|
|
@@ -53,10 +83,10 @@ class EventWriter:
|
|
|
53
83
|
severity: str = "info",
|
|
54
84
|
meta: Optional[Dict[str, Any]] = None,
|
|
55
85
|
) -> None:
|
|
56
|
-
"""Append a single event to the
|
|
86
|
+
"""Append a single event to the ``harness_events`` DB table.
|
|
57
87
|
|
|
58
|
-
|
|
59
|
-
|
|
88
|
+
Fails silently on any error to avoid disrupting the hook pipeline --
|
|
89
|
+
same contract as the historical file writer.
|
|
60
90
|
|
|
61
91
|
Args:
|
|
62
92
|
event_type: Dotted event category (e.g. "agent.dispatch").
|
|
@@ -64,30 +94,21 @@ class EventWriter:
|
|
|
64
94
|
agent: Agent involved, or empty string for non-agent events.
|
|
65
95
|
result: Outcome summary string.
|
|
66
96
|
severity: info | warning | error.
|
|
67
|
-
meta: Optional type-specific structured data
|
|
97
|
+
meta: Optional type-specific structured data (stored as JSON in
|
|
98
|
+
the ``payload`` column).
|
|
68
99
|
"""
|
|
69
100
|
try:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
record["meta"] = meta
|
|
82
|
-
|
|
83
|
-
with open(self.lock_file, "w") as lf:
|
|
84
|
-
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
|
85
|
-
try:
|
|
86
|
-
with open(self.events_file, "a") as f:
|
|
87
|
-
f.write(json.dumps(record, separators=(",", ":")) + "\n")
|
|
88
|
-
finally:
|
|
89
|
-
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
|
90
|
-
|
|
101
|
+
writer = _import_store_writer()
|
|
102
|
+
workspace = os.environ.get("GAIA_WORKSPACE") or None
|
|
103
|
+
writer.write_harness_event(
|
|
104
|
+
event_type=event_type,
|
|
105
|
+
source=source,
|
|
106
|
+
agent=agent,
|
|
107
|
+
result=result,
|
|
108
|
+
severity=severity,
|
|
109
|
+
meta=meta,
|
|
110
|
+
workspace=workspace,
|
|
111
|
+
)
|
|
91
112
|
except Exception as exc:
|
|
92
113
|
logger.debug("Event write failed (non-fatal): %s", exc)
|
|
93
114
|
|
|
@@ -98,7 +119,13 @@ def read_events(
|
|
|
98
119
|
limit: int = 50,
|
|
99
120
|
events_dir: Optional[Path] = None,
|
|
100
121
|
) -> List[Dict[str, Any]]:
|
|
101
|
-
"""Read recent events from the JSONL log.
|
|
122
|
+
"""Read recent events from the legacy JSONL log.
|
|
123
|
+
|
|
124
|
+
NOTE: As of Task 2.2 this is no longer the canonical read path -- new
|
|
125
|
+
events are written to the ``harness_events`` DB table. This reader is
|
|
126
|
+
retained read-only until Task 2.3 removes ``events.jsonl`` entirely, so
|
|
127
|
+
historical pre-cutover events remain consultable. New callers should use
|
|
128
|
+
``gaia.store.reader.cross_surface_query(surface="harness_events")``.
|
|
102
129
|
|
|
103
130
|
Args:
|
|
104
131
|
hours: How far back to look (default 24h).
|
|
@@ -148,63 +175,3 @@ def read_events(
|
|
|
148
175
|
except Exception as exc:
|
|
149
176
|
logger.debug("Event read failed (non-fatal): %s", exc)
|
|
150
177
|
return []
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def cleanup_old_events(
|
|
154
|
-
days: int = 7,
|
|
155
|
-
events_dir: Optional[Path] = None,
|
|
156
|
-
) -> int:
|
|
157
|
-
"""Remove events older than *days* from the JSONL log.
|
|
158
|
-
|
|
159
|
-
Uses file locking to avoid conflicts with concurrent writers.
|
|
160
|
-
Retains lines that cannot be parsed (conservative).
|
|
161
|
-
|
|
162
|
-
Args:
|
|
163
|
-
days: Retention window in days (default 7).
|
|
164
|
-
events_dir: Override events directory (for testing).
|
|
165
|
-
|
|
166
|
-
Returns:
|
|
167
|
-
Number of events removed.
|
|
168
|
-
"""
|
|
169
|
-
try:
|
|
170
|
-
edir = events_dir or get_events_dir()
|
|
171
|
-
events_file = edir / "events.jsonl"
|
|
172
|
-
lock_file = edir / "events.jsonl.lock"
|
|
173
|
-
|
|
174
|
-
if not events_file.exists():
|
|
175
|
-
return 0
|
|
176
|
-
|
|
177
|
-
retention_days = int(os.environ.get("GAIA_EVENT_RETENTION_DAYS", str(days)))
|
|
178
|
-
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
|
179
|
-
kept: List[str] = []
|
|
180
|
-
removed = 0
|
|
181
|
-
|
|
182
|
-
with open(lock_file, "w") as lf:
|
|
183
|
-
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
|
|
184
|
-
try:
|
|
185
|
-
with open(events_file, "r") as f:
|
|
186
|
-
for line in f:
|
|
187
|
-
line = line.strip()
|
|
188
|
-
if not line:
|
|
189
|
-
continue
|
|
190
|
-
try:
|
|
191
|
-
evt = json.loads(line)
|
|
192
|
-
ts = datetime.fromisoformat(evt["ts"])
|
|
193
|
-
if ts < cutoff:
|
|
194
|
-
removed += 1
|
|
195
|
-
continue
|
|
196
|
-
except (json.JSONDecodeError, KeyError, ValueError):
|
|
197
|
-
pass # Keep unparseable lines
|
|
198
|
-
kept.append(line)
|
|
199
|
-
|
|
200
|
-
with open(events_file, "w") as f:
|
|
201
|
-
for line in kept:
|
|
202
|
-
f.write(line + "\n")
|
|
203
|
-
finally:
|
|
204
|
-
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
|
|
205
|
-
|
|
206
|
-
return removed
|
|
207
|
-
|
|
208
|
-
except Exception as exc:
|
|
209
|
-
logger.debug("Event cleanup failed (non-fatal): %s", exc)
|
|
210
|
-
return 0
|
|
@@ -45,7 +45,6 @@ from .approval_scopes import (
|
|
|
45
45
|
from .approval_grants import (
|
|
46
46
|
check_approval_grant,
|
|
47
47
|
cleanup_expired_grants,
|
|
48
|
-
get_latest_pending_approval,
|
|
49
48
|
last_check_found_expired,
|
|
50
49
|
ApprovalGrant,
|
|
51
50
|
)
|
|
@@ -93,7 +92,6 @@ __all__ = [
|
|
|
93
92
|
# Approval Grants
|
|
94
93
|
"check_approval_grant",
|
|
95
94
|
"cleanup_expired_grants",
|
|
96
|
-
"get_latest_pending_approval",
|
|
97
95
|
"last_check_found_expired",
|
|
98
96
|
"ApprovalGrant",
|
|
99
97
|
# Shell unwrapper
|