@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
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
"""Scan for deferred pending approvals and format a human-readable summary.
|
|
1
|
+
"""Scan for deferred pending approvals and format a human-readable summary.
|
|
2
|
+
|
|
3
|
+
DB-only since Task E FS retirement:
|
|
4
|
+
``scan_pending_db()`` queries the approvals table directly and is the
|
|
5
|
+
sole canonical source for pending-approvals surfacing. All pending
|
|
6
|
+
types -- T3 commands, COMMAND_SET batches, and SCOPE_FILE_PATH
|
|
7
|
+
file-write blocks -- are written exclusively to gaia.db via
|
|
8
|
+
gaia.approvals.store.insert_requested().
|
|
9
|
+
|
|
10
|
+
``scan_pending_approvals()`` has been retired: no pending-*.json files
|
|
11
|
+
have been written since the M2 cutover. The stub returns [] so any
|
|
12
|
+
residual callers fail safely without raising.
|
|
13
|
+
``build_pending_approvals_block`` in session_manifest.py calls
|
|
14
|
+
``scan_pending_db()`` exclusively.
|
|
15
|
+
"""
|
|
2
16
|
|
|
3
17
|
import json
|
|
4
18
|
import logging
|
|
5
|
-
import os
|
|
6
19
|
import time
|
|
7
20
|
from pathlib import Path
|
|
8
21
|
from typing import List, Dict, Optional
|
|
@@ -10,109 +23,156 @@ from typing import List, Dict, Optional
|
|
|
10
23
|
logger = logging.getLogger(__name__)
|
|
11
24
|
|
|
12
25
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
current_session_id: Optional[str] = None,
|
|
17
|
-
exclude_live_sessions: bool = False,
|
|
18
|
-
) -> List[Dict]:
|
|
19
|
-
"""Scan approvals directory for pending files.
|
|
20
|
-
|
|
21
|
-
Returns list of dicts with: nonce (short), command, verb, category,
|
|
22
|
-
age_human (e.g. "14 hours ago"), context (if enriched), timestamp,
|
|
23
|
-
cross_session (bool), pending_session_id.
|
|
24
|
-
|
|
25
|
-
If session_id provided, filter to that session. Otherwise return all.
|
|
26
|
-
current_session_id is used to annotate items from prior sessions
|
|
27
|
-
(cross_session=True when pending.session_id != current_session_id).
|
|
28
|
-
|
|
29
|
-
If exclude_live_sessions is True, pendings whose owning session_id
|
|
30
|
-
is currently registered as alive (per session_registry.get_live_sessions)
|
|
31
|
-
are filtered out. This is used by the [ACTIONABLE] injection path and
|
|
32
|
-
the `gaia approvals list --orphans-only` CLI flag to avoid showing
|
|
33
|
-
cross-session pendings that a parallel live session may still resolve.
|
|
34
|
-
On registry errors the function logs a warning and returns all pendings
|
|
35
|
-
unfiltered (conservative: better to show extras than lose real pendings).
|
|
36
|
-
"""
|
|
37
|
-
results = []
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# DB-primary path (canonical since T2.1 cutover / Brief 71)
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
38
29
|
|
|
39
|
-
|
|
40
|
-
|
|
30
|
+
def scan_pending_db() -> List[Dict]:
|
|
31
|
+
"""Query the DB approvals table for currently-pending rows.
|
|
41
32
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
33
|
+
Returns the same dict shape as scan_pending_approvals() so the existing
|
|
34
|
+
format_pending_summary() and format_pending_detail() formatters work
|
|
35
|
+
unchanged. Scopes to ALL pending rows (no session filter) because:
|
|
36
|
+
* The DB is per-machine (~/.gaia/gaia.db), so cross-machine leakage is
|
|
37
|
+
impossible.
|
|
38
|
+
* The session_id stored in approvals rows is the main session_id, while
|
|
39
|
+
$CLAUDE_SESSION_ID inside a subagent is the subagent's id — filtering
|
|
40
|
+
by session would silently drop all subagent-originated pendings (the
|
|
41
|
+
known bug owned by another task; see CONFIRMED FINDINGS, Task C).
|
|
42
|
+
|
|
43
|
+
Returns [] on any error (never raises) so the caller's fail-safe catches it.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
# Lazy import: keeps gaia.approvals out of modules that only use the
|
|
47
|
+
# filesystem path. Falls back to the repo root path when the installed
|
|
48
|
+
# package is not importable (e.g. running directly from the source tree).
|
|
45
49
|
try:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if data.get("status") == "rejected":
|
|
59
|
-
try:
|
|
60
|
-
os.unlink(str(f))
|
|
61
|
-
except OSError:
|
|
62
|
-
pass
|
|
63
|
-
continue
|
|
64
|
-
# Filter by session if requested
|
|
65
|
-
if session_id and data.get("session_id") != session_id:
|
|
66
|
-
continue
|
|
67
|
-
# Format age
|
|
68
|
-
age_seconds = time.time() - data.get("timestamp", 0)
|
|
69
|
-
age_human = _format_age(age_seconds)
|
|
50
|
+
from gaia.approvals.store import list_pending
|
|
51
|
+
except ImportError:
|
|
52
|
+
import pathlib as _pl
|
|
53
|
+
import sys as _sys
|
|
54
|
+
_repo = _pl.Path(__file__).resolve().parent.parent.parent.parent.parent
|
|
55
|
+
_sys.path.insert(0, str(_repo))
|
|
56
|
+
from gaia.approvals.store import list_pending
|
|
57
|
+
|
|
58
|
+
rows = list_pending(all_sessions=True)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
logger.debug("scan_pending_db: DB query failed (non-fatal): %s", exc)
|
|
61
|
+
return []
|
|
70
62
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
63
|
+
results = []
|
|
64
|
+
now = time.time()
|
|
65
|
+
for row in rows:
|
|
66
|
+
try:
|
|
67
|
+
approval_id = row.get("id", "unknown")
|
|
68
|
+
# Short display id: strip the "P-" prefix and take first 8 chars.
|
|
69
|
+
nonce_short = approval_id[2:10] if approval_id.startswith("P-") else approval_id[:8]
|
|
70
|
+
nonce_full = approval_id[2:] if approval_id.startswith("P-") else approval_id
|
|
71
|
+
|
|
72
|
+
payload_json = row.get("payload_json") or "{}"
|
|
73
|
+
try:
|
|
74
|
+
payload = json.loads(payload_json)
|
|
75
|
+
except (json.JSONDecodeError, TypeError):
|
|
76
|
+
payload = {}
|
|
77
|
+
|
|
78
|
+
# Extract command: prefer exact_content, fall back to first
|
|
79
|
+
# command in the commands list, then the operation description.
|
|
80
|
+
command_set_items = payload.get("command_set") or []
|
|
81
|
+
commands_list = payload.get("commands") or []
|
|
82
|
+
command = (
|
|
83
|
+
payload.get("exact_content")
|
|
84
|
+
or (commands_list[0] if commands_list else None)
|
|
85
|
+
or payload.get("operation")
|
|
86
|
+
or "unknown"
|
|
75
87
|
)
|
|
88
|
+
# For COMMAND_SET: the "command" shown is a summary of all commands.
|
|
89
|
+
is_command_set = len(command_set_items) > 1 or len(commands_list) > 1
|
|
90
|
+
if is_command_set:
|
|
91
|
+
all_cmds = (
|
|
92
|
+
[it["command"] for it in command_set_items if isinstance(it, dict) and it.get("command")]
|
|
93
|
+
if command_set_items
|
|
94
|
+
else commands_list
|
|
95
|
+
)
|
|
96
|
+
if len(all_cmds) > 1:
|
|
97
|
+
command = f"[{len(all_cmds)} commands] " + (all_cmds[0] if all_cmds else command)
|
|
98
|
+
|
|
99
|
+
# Reconstruct verb and category from operation field.
|
|
100
|
+
# operation format: "{CATEGORY} command intercepted: {verb}"
|
|
101
|
+
operation = payload.get("operation", "")
|
|
102
|
+
verb = "unknown"
|
|
103
|
+
category = "MUTATIVE"
|
|
104
|
+
if ": " in operation:
|
|
105
|
+
verb = operation.rsplit(": ", 1)[-1].strip()
|
|
106
|
+
if " command intercepted" in operation:
|
|
107
|
+
category = operation.split(" command intercepted")[0].strip()
|
|
108
|
+
|
|
109
|
+
# Build a context dict that format_pending_summary can use.
|
|
110
|
+
context = {
|
|
111
|
+
"source": "db",
|
|
112
|
+
"description": payload.get("rationale", ""),
|
|
113
|
+
"risk": payload.get("risk_level", "medium"),
|
|
114
|
+
"rollback": payload.get("rollback_hint"),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Age from created_at timestamp.
|
|
118
|
+
age_seconds: float = row.get("age_seconds", 0.0)
|
|
119
|
+
if not age_seconds:
|
|
120
|
+
created_at = row.get("created_at", "")
|
|
121
|
+
if created_at:
|
|
122
|
+
try:
|
|
123
|
+
from datetime import datetime, timezone
|
|
124
|
+
dt = datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%SZ").replace(
|
|
125
|
+
tzinfo=timezone.utc
|
|
126
|
+
)
|
|
127
|
+
age_seconds = (datetime.now(timezone.utc) - dt).total_seconds()
|
|
128
|
+
except (ValueError, TypeError):
|
|
129
|
+
age_seconds = 0.0
|
|
130
|
+
age_human = _format_age(age_seconds)
|
|
131
|
+
timestamp = now - age_seconds
|
|
76
132
|
|
|
77
133
|
results.append({
|
|
78
|
-
"nonce_short":
|
|
79
|
-
"nonce_full":
|
|
80
|
-
"command":
|
|
81
|
-
"verb":
|
|
82
|
-
"category":
|
|
134
|
+
"nonce_short": nonce_short,
|
|
135
|
+
"nonce_full": nonce_full,
|
|
136
|
+
"command": command,
|
|
137
|
+
"verb": verb,
|
|
138
|
+
"category": category,
|
|
83
139
|
"age_human": age_human,
|
|
84
|
-
"timestamp":
|
|
85
|
-
"context":
|
|
86
|
-
"scope_type":
|
|
87
|
-
|
|
88
|
-
|
|
140
|
+
"timestamp": timestamp,
|
|
141
|
+
"context": context,
|
|
142
|
+
"scope_type": "db",
|
|
143
|
+
# DB-sourced pendings are not cross-session (all sessions on
|
|
144
|
+
# this machine are the same user); mark False so the formatter
|
|
145
|
+
# does not add the "[session anterior]" tag.
|
|
146
|
+
"cross_session": False,
|
|
147
|
+
"pending_session_id": row.get("session_id", "unknown"),
|
|
148
|
+
# Extra field for deduplication in the UNION path.
|
|
149
|
+
"_approval_id": approval_id,
|
|
89
150
|
})
|
|
90
|
-
except Exception:
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
logger.debug("scan_pending_db: skipping row %s: %s", row.get("id"), exc)
|
|
91
153
|
continue
|
|
92
154
|
|
|
93
|
-
# Optionally exclude pendings whose owning session is currently alive.
|
|
94
|
-
# Lazy import keeps the registry dependency out of modules that call
|
|
95
|
-
# scan_pending_approvals() without the flag.
|
|
96
|
-
if exclude_live_sessions:
|
|
97
|
-
try:
|
|
98
|
-
from modules.session.session_registry import get_live_sessions
|
|
99
|
-
# Exclude headless sessions from the live-set: nobody is
|
|
100
|
-
# watching them interactively, so their pendings must surface
|
|
101
|
-
# to interactive sessions that can approve/reject them.
|
|
102
|
-
live = get_live_sessions(include_headless=False)
|
|
103
|
-
results = [r for r in results if r["pending_session_id"] not in live]
|
|
104
|
-
except Exception as exc: # noqa: BLE001 — deliberate broad catch
|
|
105
|
-
logger.warning(
|
|
106
|
-
"scan_pending_approvals: get_live_sessions() failed (%s) "
|
|
107
|
-
"— returning all pendings unfiltered",
|
|
108
|
-
exc,
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
# Sort by timestamp (oldest first)
|
|
112
155
|
results.sort(key=lambda x: x["timestamp"])
|
|
113
156
|
return results
|
|
114
157
|
|
|
115
158
|
|
|
159
|
+
def scan_pending_approvals(
|
|
160
|
+
approvals_dir: Path,
|
|
161
|
+
session_id: Optional[str] = None,
|
|
162
|
+
current_session_id: Optional[str] = None,
|
|
163
|
+
exclude_live_sessions: bool = False,
|
|
164
|
+
) -> List[Dict]:
|
|
165
|
+
"""Filesystem pending scan — retired as of Task E FS retirement.
|
|
166
|
+
|
|
167
|
+
No new pending-*.json files are written after the M2 cutover.
|
|
168
|
+
The DB is the sole pending store; use scan_pending_db() instead.
|
|
169
|
+
|
|
170
|
+
Signature preserved for backward compatibility with callers that still
|
|
171
|
+
reference the function. Returns [] unconditionally.
|
|
172
|
+
"""
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
|
|
116
176
|
def _truncate_smart(cmd: str, max_len: int = 100) -> str:
|
|
117
177
|
"""Truncate a command string with head+tail context when it exceeds max_len.
|
|
118
178
|
|
|
@@ -455,47 +455,41 @@ def build_projects_context_block(max_chars: int = 1400) -> str:
|
|
|
455
455
|
def build_pending_approvals_block() -> str:
|
|
456
456
|
"""Build the [ACTIONABLE] pending-approvals block, if any exist.
|
|
457
457
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
458
|
+
DB-only since Task E of the approval redesign: all pending types
|
|
459
|
+
(T3 commands, COMMAND_SET batches, and SCOPE_FILE_PATH file-write
|
|
460
|
+
blocks) are now written exclusively to gaia.db via
|
|
461
|
+
gaia.approvals.store.insert_requested(). The filesystem supplement
|
|
462
|
+
that was kept in Tasks C-D is removed: scan_pending_db() is the sole
|
|
463
|
+
read source.
|
|
464
|
+
|
|
465
|
+
Scoping: DB query uses all_sessions=True (no session filter). The
|
|
466
|
+
session_id stored in approval rows is the main session while
|
|
467
|
+
$CLAUDE_SESSION_ID inside a subagent is the subagent id -- filtering by
|
|
468
|
+
session would silently drop all subagent pendings. The DB is
|
|
469
|
+
per-machine so all rows are from the same user.
|
|
463
470
|
|
|
464
471
|
Returns "" when no pendings are surfaced. Never raises.
|
|
465
472
|
"""
|
|
466
473
|
try:
|
|
467
|
-
from ..core.paths import get_plugin_data_dir
|
|
468
|
-
from ..core.state import get_session_id
|
|
469
474
|
from .pending_scanner import (
|
|
470
475
|
format_pending_summary,
|
|
471
|
-
|
|
476
|
+
scan_pending_db,
|
|
472
477
|
)
|
|
473
478
|
|
|
474
|
-
|
|
475
|
-
session_id = get_session_id()
|
|
476
|
-
|
|
477
|
-
pendings = scan_pending_approvals(
|
|
478
|
-
approvals_dir,
|
|
479
|
-
session_id=session_id,
|
|
480
|
-
current_session_id=session_id,
|
|
481
|
-
)
|
|
482
|
-
|
|
483
|
-
# Cross-session fallback. exclude_live_sessions=True drops pendings
|
|
484
|
-
# from parallel live sessions so we don't double-surface them in
|
|
485
|
-
# two interactive Claude Code windows. include_headless=False is
|
|
486
|
-
# already applied inside scan_pending_approvals.
|
|
487
|
-
if not pendings:
|
|
488
|
-
pendings = scan_pending_approvals(
|
|
489
|
-
approvals_dir,
|
|
490
|
-
current_session_id=session_id,
|
|
491
|
-
exclude_live_sessions=True,
|
|
492
|
-
)
|
|
479
|
+
pendings = scan_pending_db()
|
|
493
480
|
|
|
494
481
|
if not pendings:
|
|
495
482
|
return ""
|
|
496
483
|
|
|
484
|
+
# Sort oldest-first so the orchestrator sees the most urgent
|
|
485
|
+
# (longest-waiting) pending first.
|
|
486
|
+
pendings.sort(key=lambda x: x["timestamp"])
|
|
487
|
+
|
|
497
488
|
summary = format_pending_summary(pendings)
|
|
498
|
-
logger.info(
|
|
489
|
+
logger.info(
|
|
490
|
+
"SessionStart: %d pending approval(s) surfaced (DB-only)",
|
|
491
|
+
len(pendings),
|
|
492
|
+
)
|
|
499
493
|
return (
|
|
500
494
|
"[ACTIONABLE] Pending approvals require your attention before "
|
|
501
495
|
"routing the next request.\n\n" + summary
|
|
@@ -505,6 +499,241 @@ def build_pending_approvals_block() -> str:
|
|
|
505
499
|
return ""
|
|
506
500
|
|
|
507
501
|
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
# Per-turn VERIFIED pending approvals (UserPromptSubmit)
|
|
504
|
+
# ---------------------------------------------------------------------------
|
|
505
|
+
#
|
|
506
|
+
# Why a SEPARATE builder from build_pending_approvals_block():
|
|
507
|
+
#
|
|
508
|
+
# The SessionStart block above is a one-shot, human-readable *summary*
|
|
509
|
+
# (format_pending_summary) deliberately moved OUT of per-turn UserPromptSubmit
|
|
510
|
+
# because re-emitting a summary on every prompt added noise without changing
|
|
511
|
+
# the answer (see this module's header, "What does NOT move").
|
|
512
|
+
#
|
|
513
|
+
# The per-turn injection here solves a DIFFERENT problem: it lets the
|
|
514
|
+
# orchestrator PRESENT an approval for informed consent directly from injected
|
|
515
|
+
# context, WITHOUT dispatching a subagent to derive/verify it. That dispatch's
|
|
516
|
+
# SubagentStop is what caused a pending-revocation bug. To present without a
|
|
517
|
+
# dispatch the orchestrator needs the full sealed payload (verbatim
|
|
518
|
+
# exact_content, the command_set list, scope/risk/rationale/rollback) AND a
|
|
519
|
+
# trustworthy VERIFIED marker -- none of which the summary carries.
|
|
520
|
+
#
|
|
521
|
+
# Noise control (the reason the summary was moved to SessionStart): this
|
|
522
|
+
# builder emits "" when there are no currently-pending VERIFIED approvals, so a
|
|
523
|
+
# turn with nothing pending injects nothing. It only speaks when there is
|
|
524
|
+
# actionable, verified state to present.
|
|
525
|
+
#
|
|
526
|
+
# Scoping: identical to scan_pending_db() / build_pending_approvals_block() --
|
|
527
|
+
# all_sessions=True (no session filter). The DB is per-machine so every row is
|
|
528
|
+
# the same user, and pendings are written under the MAIN session while a
|
|
529
|
+
# subagent's $CLAUDE_SESSION_ID differs; a session filter would silently drop
|
|
530
|
+
# subagent-originated pendings.
|
|
531
|
+
|
|
532
|
+
def build_verified_pending_approvals() -> list:
|
|
533
|
+
"""Return the currently-pending approvals whose fingerprint VERIFIES.
|
|
534
|
+
|
|
535
|
+
Reads pending rows via the same all_sessions=True DB scope as
|
|
536
|
+
scan_pending_db(), then gates each row through
|
|
537
|
+
gaia.approvals.chain.verify_fingerprint(): a row is included ONLY when its
|
|
538
|
+
stored payload re-canonicalizes to the fingerprint recorded in its
|
|
539
|
+
REQUESTED event. A row that fails verification (tampered, or with no
|
|
540
|
+
REQUESTED baseline) is skipped and never returned as presentable.
|
|
541
|
+
|
|
542
|
+
Each returned dict carries everything the orchestrator needs to present the
|
|
543
|
+
approval for informed consent without any further dispatch::
|
|
544
|
+
|
|
545
|
+
{
|
|
546
|
+
"approval_id": "P-...", # full id; verbatim Approve label uses [P-<nonce8>]
|
|
547
|
+
"nonce_short": "<8 hex>", # display nonce for the [P-<nonce8>] label
|
|
548
|
+
"verified": True, # always True for returned rows
|
|
549
|
+
"operation": "...", # sealed payload field
|
|
550
|
+
"exact_content": "...", # verbatim command/content
|
|
551
|
+
"scope": "...",
|
|
552
|
+
"risk_level": "low|medium|high",
|
|
553
|
+
"rationale": "...",
|
|
554
|
+
"rollback_hint": "..." | None,
|
|
555
|
+
"command_set": [ {"command": "...", "rationale": "..."}, ... ], # [] if singular
|
|
556
|
+
"age_human": "5 min", # freshness indicator
|
|
557
|
+
"age_seconds": 300.0,
|
|
558
|
+
"session_id": "<originating session>",
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
Returns [] on any error (never raises) so the caller's fail-safe applies.
|
|
562
|
+
"""
|
|
563
|
+
try:
|
|
564
|
+
try:
|
|
565
|
+
from gaia.approvals.store import list_pending
|
|
566
|
+
from gaia.approvals.chain import verify_fingerprint, ChainTamperError
|
|
567
|
+
from gaia.store.writer import _connect as _connect_db
|
|
568
|
+
except ImportError:
|
|
569
|
+
import pathlib as _pl
|
|
570
|
+
import sys as _sys
|
|
571
|
+
_repo = _pl.Path(__file__).resolve().parents[4]
|
|
572
|
+
_sys.path.insert(0, str(_repo))
|
|
573
|
+
from gaia.approvals.store import list_pending
|
|
574
|
+
from gaia.approvals.chain import verify_fingerprint, ChainTamperError
|
|
575
|
+
from gaia.store.writer import _connect as _connect_db
|
|
576
|
+
|
|
577
|
+
from .pending_scanner import _format_age
|
|
578
|
+
|
|
579
|
+
rows = list_pending(all_sessions=True)
|
|
580
|
+
except Exception as exc:
|
|
581
|
+
logger.debug("build_verified_pending_approvals: read failed (non-fatal): %s", exc)
|
|
582
|
+
return []
|
|
583
|
+
|
|
584
|
+
if not rows:
|
|
585
|
+
return []
|
|
586
|
+
|
|
587
|
+
verified: list = []
|
|
588
|
+
con = None
|
|
589
|
+
try:
|
|
590
|
+
con = _connect_db()
|
|
591
|
+
for row in rows:
|
|
592
|
+
try:
|
|
593
|
+
approval_id = row.get("id")
|
|
594
|
+
if not approval_id:
|
|
595
|
+
continue
|
|
596
|
+
payload_json = row.get("payload_json") or "{}"
|
|
597
|
+
|
|
598
|
+
# VERIFIED gate: only rows whose stored payload matches the
|
|
599
|
+
# fingerprint in their REQUESTED event are presentable. A
|
|
600
|
+
# tamper (ChainTamperError) or a missing REQUESTED baseline
|
|
601
|
+
# (ValueError) means the row is NOT safe to present -- skip it.
|
|
602
|
+
try:
|
|
603
|
+
ok = verify_fingerprint(approval_id, payload_json, con)
|
|
604
|
+
except (ChainTamperError, ValueError) as verr:
|
|
605
|
+
logger.debug(
|
|
606
|
+
"build_verified_pending_approvals: %s fails verification, "
|
|
607
|
+
"skipping (non-fatal): %s", approval_id, verr,
|
|
608
|
+
)
|
|
609
|
+
continue
|
|
610
|
+
if not ok:
|
|
611
|
+
continue
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
payload = json.loads(payload_json)
|
|
615
|
+
except (json.JSONDecodeError, TypeError):
|
|
616
|
+
payload = {}
|
|
617
|
+
|
|
618
|
+
nonce_short = (
|
|
619
|
+
approval_id[2:10] if approval_id.startswith("P-")
|
|
620
|
+
else approval_id[:8]
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
command_set = payload.get("command_set") or []
|
|
624
|
+
# Normalize command_set items to {command, rationale}.
|
|
625
|
+
norm_command_set = []
|
|
626
|
+
if isinstance(command_set, list):
|
|
627
|
+
for it in command_set:
|
|
628
|
+
if isinstance(it, dict) and it.get("command"):
|
|
629
|
+
norm_command_set.append({
|
|
630
|
+
"command": it.get("command"),
|
|
631
|
+
"rationale": it.get("rationale", ""),
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
age_seconds = row.get("age_seconds", 0.0) or 0.0
|
|
635
|
+
|
|
636
|
+
verified.append({
|
|
637
|
+
"approval_id": approval_id,
|
|
638
|
+
"nonce_short": nonce_short,
|
|
639
|
+
"verified": True,
|
|
640
|
+
"operation": payload.get("operation", ""),
|
|
641
|
+
"exact_content": payload.get("exact_content", ""),
|
|
642
|
+
"scope": payload.get("scope", ""),
|
|
643
|
+
"risk_level": payload.get("risk_level", "medium"),
|
|
644
|
+
"rationale": payload.get("rationale", ""),
|
|
645
|
+
"rollback_hint": payload.get("rollback_hint"),
|
|
646
|
+
"command_set": norm_command_set,
|
|
647
|
+
"age_human": _format_age(age_seconds),
|
|
648
|
+
"age_seconds": age_seconds,
|
|
649
|
+
"session_id": row.get("session_id", "unknown"),
|
|
650
|
+
})
|
|
651
|
+
except Exception as exc:
|
|
652
|
+
logger.debug(
|
|
653
|
+
"build_verified_pending_approvals: skipping row %s: %s",
|
|
654
|
+
row.get("id"), exc,
|
|
655
|
+
)
|
|
656
|
+
continue
|
|
657
|
+
except Exception as exc:
|
|
658
|
+
logger.debug("build_verified_pending_approvals: failed (non-fatal): %s", exc)
|
|
659
|
+
return []
|
|
660
|
+
finally:
|
|
661
|
+
if con is not None:
|
|
662
|
+
try:
|
|
663
|
+
con.close()
|
|
664
|
+
except Exception:
|
|
665
|
+
pass
|
|
666
|
+
|
|
667
|
+
# Oldest-first so the longest-waiting (most urgent) approval is presented first.
|
|
668
|
+
verified.sort(key=lambda x: x.get("age_seconds", 0.0), reverse=True)
|
|
669
|
+
return verified
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def build_per_turn_pending_approvals_block() -> str:
|
|
673
|
+
"""Render the per-turn VERIFIED pending-approvals block for UserPromptSubmit.
|
|
674
|
+
|
|
675
|
+
Emits "" when there are no currently-pending VERIFIED approvals -- a turn
|
|
676
|
+
with nothing pending injects nothing (the noise concern that motivated
|
|
677
|
+
moving the SessionStart summary out of per-turn injection).
|
|
678
|
+
|
|
679
|
+
When verified pendings exist, renders a concise structured block carrying,
|
|
680
|
+
per approval, the full sealed payload the orchestrator needs to present for
|
|
681
|
+
informed consent WITHOUT dispatching a subagent. The block is explicitly
|
|
682
|
+
marked VERIFIED so the orchestrator knows every entry passed
|
|
683
|
+
verify_fingerprint and is safe to present verbatim.
|
|
684
|
+
|
|
685
|
+
Returns "" on any error (never raises).
|
|
686
|
+
"""
|
|
687
|
+
try:
|
|
688
|
+
pendings = build_verified_pending_approvals()
|
|
689
|
+
if not pendings:
|
|
690
|
+
return ""
|
|
691
|
+
|
|
692
|
+
lines = [
|
|
693
|
+
"[PENDING-APPROVALS-VERIFIED] "
|
|
694
|
+
f"{len(pendings)} pending approval(s) verified and presentable "
|
|
695
|
+
"WITHOUT dispatch. Present any of these for consent directly from "
|
|
696
|
+
"this block; do NOT dispatch a subagent to derive or verify them. "
|
|
697
|
+
"The Approve label is [P-<nonce8>].",
|
|
698
|
+
"",
|
|
699
|
+
]
|
|
700
|
+
for i, p in enumerate(pendings, 1):
|
|
701
|
+
lines.append(
|
|
702
|
+
f"### #{i} [P-{p['nonce_short']}] (approval_id: {p['approval_id']})"
|
|
703
|
+
)
|
|
704
|
+
lines.append(f"- verified: true")
|
|
705
|
+
lines.append(f"- operation: {p['operation']}")
|
|
706
|
+
lines.append(f"- risk_level: {p['risk_level']}")
|
|
707
|
+
lines.append(f"- scope: {p['scope']}")
|
|
708
|
+
lines.append(f"- age: {p['age_human']}")
|
|
709
|
+
if p.get("rationale"):
|
|
710
|
+
lines.append(f"- rationale: {p['rationale']}")
|
|
711
|
+
if p.get("rollback_hint"):
|
|
712
|
+
lines.append(f"- rollback_hint: {p['rollback_hint']}")
|
|
713
|
+
if p.get("command_set"):
|
|
714
|
+
lines.append(
|
|
715
|
+
f"- command_set ({len(p['command_set'])} commands, "
|
|
716
|
+
f"single approval_id {p['approval_id']}):"
|
|
717
|
+
)
|
|
718
|
+
for c in p["command_set"]:
|
|
719
|
+
rat = f" # {c['rationale']}" if c.get("rationale") else ""
|
|
720
|
+
lines.append(f" - {c['command']}{rat}")
|
|
721
|
+
else:
|
|
722
|
+
lines.append(f"- exact_content: {p['exact_content']}")
|
|
723
|
+
lines.append("")
|
|
724
|
+
|
|
725
|
+
logger.info(
|
|
726
|
+
"UserPromptSubmit: %d verified pending approval(s) injected per-turn",
|
|
727
|
+
len(pendings),
|
|
728
|
+
)
|
|
729
|
+
return "\n".join(lines).rstrip()
|
|
730
|
+
except Exception as exc:
|
|
731
|
+
logger.debug(
|
|
732
|
+
"build_per_turn_pending_approvals_block failed (non-fatal): %s", exc
|
|
733
|
+
)
|
|
734
|
+
return ""
|
|
735
|
+
|
|
736
|
+
|
|
508
737
|
# ---------------------------------------------------------------------------
|
|
509
738
|
# Assembler
|
|
510
739
|
# ---------------------------------------------------------------------------
|