@jaguilar87/gaia 5.0.7 → 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.
Files changed (99) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +13 -0
  4. package/bin/README.md +6 -1
  5. package/bin/cli/approvals.py +486 -474
  6. package/bin/cli/brief.py +13 -0
  7. package/bin/cli/doctor.py +1 -1
  8. package/dist/gaia-ops/.claude-plugin/plugin.json +1 -1
  9. package/dist/gaia-ops/hooks/adapters/claude_code.py +92 -86
  10. package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
  11. package/dist/gaia-ops/hooks/modules/context/context_injector.py +23 -7
  12. package/dist/gaia-ops/hooks/modules/events/event_writer.py +63 -96
  13. package/dist/gaia-ops/hooks/modules/security/__init__.py +0 -2
  14. package/dist/gaia-ops/hooks/modules/security/approval_cleanup.py +238 -69
  15. package/dist/gaia-ops/hooks/modules/security/approval_grants.py +506 -1103
  16. package/dist/gaia-ops/hooks/modules/security/mutative_verbs.py +24 -1
  17. package/dist/gaia-ops/hooks/modules/session/pending_scanner.py +150 -90
  18. package/dist/gaia-ops/hooks/modules/session/session_manifest.py +257 -28
  19. package/dist/gaia-ops/hooks/modules/tools/bash_validator.py +19 -0
  20. package/dist/gaia-ops/hooks/post_compact.py +1 -0
  21. package/dist/gaia-ops/hooks/pre_compact.py +1 -0
  22. package/dist/gaia-ops/hooks/user_prompt_submit.py +20 -0
  23. package/dist/gaia-ops/skills/agent-approval-protocol/SKILL.md +50 -14
  24. package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +16 -9
  25. package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
  26. package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
  27. package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -22
  28. package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
  29. package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +20 -14
  30. package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
  31. package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +28 -3
  32. package/dist/gaia-ops/skills/subagent-request-approval/reference.md +34 -8
  33. package/dist/gaia-ops/tools/migration/README.md +10 -12
  34. package/dist/gaia-ops/tools/scan/orchestrator.py +194 -10
  35. package/dist/gaia-ops/tools/scan/tests/test_integration.py +1 -2
  36. package/dist/gaia-security/.claude-plugin/plugin.json +1 -1
  37. package/dist/gaia-security/hooks/adapters/claude_code.py +92 -86
  38. package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
  39. package/dist/gaia-security/hooks/modules/context/context_injector.py +23 -7
  40. package/dist/gaia-security/hooks/modules/events/event_writer.py +63 -96
  41. package/dist/gaia-security/hooks/modules/security/__init__.py +0 -2
  42. package/dist/gaia-security/hooks/modules/security/approval_cleanup.py +238 -69
  43. package/dist/gaia-security/hooks/modules/security/approval_grants.py +506 -1103
  44. package/dist/gaia-security/hooks/modules/security/mutative_verbs.py +24 -1
  45. package/dist/gaia-security/hooks/modules/session/pending_scanner.py +150 -90
  46. package/dist/gaia-security/hooks/modules/session/session_manifest.py +257 -28
  47. package/dist/gaia-security/hooks/modules/tools/bash_validator.py +19 -0
  48. package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
  49. package/gaia/approvals/__init__.py +2 -1
  50. package/gaia/approvals/store.py +165 -15
  51. package/gaia/store/schema.sql +38 -1
  52. package/gaia/store/writer.py +400 -0
  53. package/hooks/adapters/claude_code.py +92 -86
  54. package/hooks/elicitation_result.py +20 -75
  55. package/hooks/modules/agents/handoff_persister.py +13 -2
  56. package/hooks/modules/context/context_injector.py +23 -7
  57. package/hooks/modules/events/event_writer.py +63 -96
  58. package/hooks/modules/security/__init__.py +0 -2
  59. package/hooks/modules/security/approval_cleanup.py +238 -69
  60. package/hooks/modules/security/approval_grants.py +506 -1103
  61. package/hooks/modules/security/mutative_verbs.py +24 -1
  62. package/hooks/modules/session/pending_scanner.py +150 -90
  63. package/hooks/modules/session/session_manifest.py +257 -28
  64. package/hooks/modules/tools/bash_validator.py +19 -0
  65. package/hooks/post_compact.py +1 -0
  66. package/hooks/pre_compact.py +1 -0
  67. package/hooks/user_prompt_submit.py +20 -0
  68. package/package.json +1 -1
  69. package/pyproject.toml +1 -1
  70. package/scripts/bootstrap_database.sh +66 -17
  71. package/scripts/migrations/README.md +26 -14
  72. package/scripts/migrations/schema.checksum +2 -2
  73. package/scripts/migrations/v18_to_v19.sql +36 -0
  74. package/scripts/migrations/v19_to_v20.sql +20 -0
  75. package/skills/agent-approval-protocol/SKILL.md +50 -14
  76. package/skills/agent-approval-protocol/reference.md +16 -9
  77. package/skills/agent-protocol/examples.md +12 -1
  78. package/skills/gaia-patterns/reference.md +2 -2
  79. package/skills/orchestrator-present-approval/SKILL.md +69 -22
  80. package/skills/orchestrator-present-approval/reference.md +16 -3
  81. package/skills/orchestrator-present-approval/template.md +20 -14
  82. package/skills/pending-approvals/SKILL.md +16 -11
  83. package/skills/subagent-request-approval/SKILL.md +28 -3
  84. package/skills/subagent-request-approval/reference.md +34 -8
  85. package/tools/migration/README.md +10 -12
  86. package/tools/scan/orchestrator.py +194 -10
  87. package/tools/scan/tests/test_integration.py +1 -2
  88. package/bin/cli/plans.py +0 -517
  89. package/dist/gaia-ops/tools/context/deep_merge.py +0 -159
  90. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.py +0 -132
  91. package/dist/gaia-ops/tools/migration/migrate_04_harness_events.sh +0 -23
  92. package/dist/gaia-ops/tools/scan/merge.py +0 -213
  93. package/dist/gaia-ops/tools/scan/tests/test_merge.py +0 -269
  94. package/gaia/approvals/revert.py +0 -282
  95. package/tools/context/deep_merge.py +0 -159
  96. package/tools/migration/migrate_04_harness_events.py +0 -132
  97. package/tools/migration/migrate_04_harness_events.sh +0 -23
  98. package/tools/scan/merge.py +0 -213
  99. package/tools/scan/tests/test_merge.py +0 -269
@@ -44,9 +44,20 @@ Add an `approval_request` to your `agent_contract_handoff`, copying the hook's f
44
44
 
45
45
  The `approval_request` schema is canonical in `agent-approval-protocol` — relay the sealed_payload fields verbatim (the hook built them) and add `verification` (your own success criteria) + `approval_id` (the literal token from the denial). See `agent-approval-protocol/SKILL.md` for the full field list and types.
46
46
 
47
- The `approval_id` is the `P-{...}` token the orchestrator uses to find the
48
- `REQUESTED` row in the DB and validate the fingerprint. Fields written only in
49
- prose are invisible to the presentation -- the user would approve blind.
47
+ The `approval_id` is the `P-{...}` token tying this request to its `REQUESTED`
48
+ row in the DB. Fields written only in prose are invisible to the presentation --
49
+ the user would approve blind.
50
+
51
+ **What your relay is for: same-turn immediacy.** Your `approval_request` is the
52
+ orchestrator's source only for the CURRENT turn. The orchestrator's primary
53
+ source is the per-turn `[PENDING-APPROVALS-VERIFIED]` block injected at
54
+ `UserPromptSubmit`, which carries every pending that has survived >= 1 turn,
55
+ already DB-read and fingerprint-verified. But that block was built before you
56
+ ran this turn, so a pending you mint now is not in it yet -- the orchestrator
57
+ presents it from your relay until the next turn's block picks it up. You emit the
58
+ same fields either way; nothing on your side changes. The orchestrator never
59
+ dispatches a subagent to verify or derive your request -- integrity is enforced
60
+ at grant activation, not at presentation.
50
61
 
51
62
  ## Non-negotiable rules
52
63
 
@@ -99,6 +110,20 @@ with one `approval_id` -- so a batch of N commands is **one consent, N
99
110
  commands**, not N approvals. A set of `<= 1` item is not a batch: it does not
100
111
  mint a COMMAND_SET (use the normal singular block path for a single command).
101
112
 
113
+ You still emit the `command_set` with **no `approval_id`** -- nothing changes on
114
+ your side. What changed underneath: the minted `approval_id` is now
115
+ **content-derived** from the command_set
116
+ (`derive_command_set_id` -> `P-<first 32 hex of sha256(canonical commands)>`),
117
+ not a random uuid4. You do not compute or emit it (you cannot hash reliably, and
118
+ you have nothing to attempt yet); the value is purely internal. The reason it
119
+ matters: the content-derived id is reproducible without a uuid4 that could be
120
+ lost across sessions. Once the minted pending has survived a turn, the
121
+ orchestrator reads it -- with all N commands -- straight from the injected
122
+ `[PENDING-APPROVALS-VERIFIED]` block (no DB search, no derive-dispatch); for the
123
+ turn you mint it in, the orchestrator presents from the `command_set` in your
124
+ relay. Your contract stays the same -- `command_set` of `{command, rationale}`
125
+ items, no `approval_id`.
126
+
102
127
  On the user's approval, that one pending activates into a single `COMMAND_SET`
103
128
  grant (60-minute TTL); each item is then consumed byte-for-byte on its own
104
129
  retry, with replay protection, until the whole set is `CONSUMED`. See
@@ -16,8 +16,12 @@ payload from the intercepted command and calls
16
16
  3. writes the `REQUESTED` event to the DB.
17
17
 
18
18
  The block message you receive (`[T3_BLOCKED] ...`) ends with `approval_id: P-{...}`.
19
- You relay that token plus the operation details; the orchestrator re-derives the
20
- fingerprint from the DB row.
19
+ You relay that token plus the operation details. For the current turn the
20
+ orchestrator presents from your relay; once the pending survives a turn it
21
+ appears in the per-turn `[PENDING-APPROVALS-VERIFIED]` block, already
22
+ fingerprint-verified by the hook. Payload integrity is enforced at grant
23
+ activation (`verify_fingerprint`), so the orchestrator never dispatches to
24
+ verify or derive your request.
21
25
 
22
26
  Source: `bash_validator._build_sealed_payload()`, the subagent block path in
23
27
  `bash_validator._validate_single_command()`; `gaia/approvals/store.py`
@@ -94,6 +98,24 @@ COMMAND_SET is ever minted for one command). The intake runs independently of
94
98
  the audit handoff-row write, so a batch consent is never lost to an unrelated
95
99
  DB failure.
96
100
 
101
+ **The COMMAND_SET `approval_id` is content-derived, not uuid4.** Unlike the
102
+ singular hook-block path (which mints `P-{uuid4hex}`), the intake derives the id
103
+ from the command_set content via `gaia.approvals.store.derive_command_set_id()`:
104
+ `P-<first 32 hex of sha256(canonical(post-filter command strings))>`. It then
105
+ passes that id to `insert_requested(..., approval_id=...)` as the pending row id.
106
+ The point is reproducibility without a fragile uuid4: a uuid4 minted at
107
+ SubagentStop could not be recovered by the parent (Claude Code #5812), but a
108
+ content-derived id needs no recovery -- the same canonicalization
109
+ (`chain.canonical_payload`) and mutative filter always yield the same id. Once
110
+ the minted pending survives a turn, the orchestrator reads that id (and all N
111
+ commands) straight from the injected `[PENDING-APPROVALS-VERIFIED]` block -- no
112
+ DB lookup and no `gaia approvals derive-id` dispatch; for the mint turn it
113
+ presents from the `command_set` in your relay. The id is
114
+ **order-sensitive** (the consume side matches positionally) and **content-only**
115
+ (rationale/session/agent are not folded in, so both sides agree from the command
116
+ list alone). Idempotency follows the existing fingerprint dedup: two identical
117
+ command sets map to one id.
118
+
97
119
  **Envelope shape.** The sealed_payload the intake writes carries a `command_set`
98
120
  key holding the verbatim list of `{command, rationale}` items, and `commands`
99
121
  listing every command string in the set:
@@ -138,13 +160,17 @@ single-use within the 60-minute window.
138
160
  Always `plan_status: "APPROVAL_REQUEST"`. The presence of `approval_id` tells the
139
161
  orchestrator which path:
140
162
 
141
- - **With `approval_id`** -- the hook blocked a single command; orchestrator
142
- validates the fingerprint and activates the single-use semantic grant on user
143
- approval.
163
+ - **With `approval_id`** -- the hook blocked a single command; the orchestrator
164
+ presents from your relay (current turn) or the injected
165
+ `[PENDING-APPROVALS-VERIFIED]` block (later turns), and the single-use semantic
166
+ grant activates on user approval (fingerprint checked at activation).
144
167
  - **Without `approval_id`, with a `command_set` of >= 2 items** -- plan-first
145
- batch. The SubagentStop intake processor mints ONE pending `COMMAND_SET` and
146
- the orchestrator presents that single approval (N commands, one nonce) before
147
- any execution. See "Batch / COMMAND_SET -- wired" above.
168
+ batch. The SubagentStop intake processor mints ONE pending `COMMAND_SET` with a
169
+ **content-derived** id (`derive_command_set_id`). The orchestrator reads that
170
+ id and the N commands from the injected `[PENDING-APPROVALS-VERIFIED]` block
171
+ (no derive-dispatch), or, for the mint turn, from the `command_set` in your
172
+ relay, then presents the single approval (N commands, one nonce). See
173
+ "Batch / COMMAND_SET -- wired" above.
148
174
  - **Without `approval_id` and without a multi-item `command_set`** -- plan-first
149
175
  single (you are presenting one T3 plan before attempting); the orchestrator
150
176
  gates on user consent before any execution.
@@ -19,7 +19,12 @@ desde el filesystem hacia `~/.gaia/gaia.db`.
19
19
  | 01 | Episodes | `.claude/project-context/episodic-memory/episodes.jsonl` | `episodes` (+`episodes_fts`) |
20
20
  | 02 | Memory | `~/.claude/projects/-home-jorge-ws-me/memory/*.md` | `memory` (+`memory_fts`) |
21
21
  | 03 | Context contracts | `.claude/project-context/project-context.json` | `context_contracts` |
22
- | 04 | Harness events | `.claude/events/events.jsonl` | `harness_events` |
22
+ | 04 | Harness events | ~~`.claude/events/events.jsonl`~~ (ELIMINADO) | `harness_events` |
23
+
24
+ > **Dominio 04 completado y eliminado.** `events.jsonl` y su archivo `.lock` fueron
25
+ > retirados. El hook `event_writer` escribe directamente a `harness_events` en la DB.
26
+ > El script `migrate_04_harness_events.py` y su wrapper `.sh` fueron borrados una vez
27
+ > completada la absorción. Los datos vivos se leen desde `harness_events` en `~/.gaia/gaia.db`.
23
28
 
24
29
  Cada dominio tiene 2 archivos:
25
30
 
@@ -37,8 +42,8 @@ bootstrap.sh # crea/inicializa ~/.gaia/gaia.db con s
37
42
  ./migrate_01_episodes.sh # ~50-80 MB de SQL, batch 80
38
43
  ./migrate_02_memory.sh # 28 .md (MEMORY.md excluido)
39
44
  ./migrate_03_context_contracts.sh # 12 secciones
40
- ./migrate_04_harness_events.sh # ~5-10 MB de SQL, batch 200
41
- ./validate.sh # 5 aserciones read-only
45
+ # migrate_04_harness_events.sh ELIMINADO dominio 04 completado; eventos en DB-canonical
46
+ ./validate.sh # aserciones read-only (V4 eliminada junto con 04)
42
47
  ```
43
48
 
44
49
  Cada script imprime `[migrate_NN] OK` al terminar.
@@ -50,14 +55,7 @@ Cada script imprime `[migrate_NN] OK` al terminar.
50
55
  | 01 episodes | `INSERT OR IGNORE` (PK = `episode_id`) | sí |
51
56
  | 02 memory | `INSERT OR IGNORE` (PK = `(project, name)`) | sí |
52
57
  | 03 context_contracts | `INSERT OR IGNORE` (PK = `(project, section_name)`) | sí |
53
- | 04 harness_events | `INSERT` simple (sin PK natural) | **no duplica filas** |
54
-
55
- Para re-ejecutar 04 limpiamente:
56
-
57
- ```
58
- sqlite3 ~/.gaia/gaia.db "DELETE FROM harness_events WHERE project='me';"
59
- ./migrate_04_harness_events.sh
60
- ```
58
+ | 04 harness_events | N/A tool eliminado; escritura vía `event_writer` DB-direct | N/A |
61
59
 
62
60
  ## Validación
63
61
 
@@ -68,7 +66,7 @@ sqlite3 ~/.gaia/gaia.db "DELETE FROM harness_events WHERE project='me';"
68
66
  | V1 | `COUNT(*) FROM episodes` == líneas no vacías de `episodes.jsonl` |
69
67
  | V2 | `COUNT(*) FROM memory` == archivos `.md` (excluyendo `MEMORY.md`) |
70
68
  | V3 | `COUNT(*) FROM context_contracts` == 12 |
71
- | V4 | `COUNT(*) FROM harness_events` == líneas no vacías de `events.jsonl` |
69
+ | ~~V4~~ | ~~`COUNT(*) FROM harness_events` == líneas no vacías de `events.jsonl`~~ — eliminado junto con el dominio 04 |
72
70
  | V5 | `COUNT(*) FROM episodes_fts` == `COUNT(*) FROM episodes` (FTS sync) |
73
71
 
74
72
  Exit code: 0 si todas pasan, 1 si alguna falla.
@@ -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
- section_owners = self.registry.get_section_owners()
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.merge import AGENT_ENRICHED_SECTIONS
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.7",
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",
@@ -603,13 +603,18 @@ class ClaudeCodeAdapter(HookAdapter):
603
603
  exit_code=2,
604
604
  )
605
605
 
606
- # Save state for post-hook
606
+ # Save state for post-hook. When the command was allowed by consuming a
607
+ # T3 approval grant, carry that approval_id forward so PostToolUse can
608
+ # append an EXECUTED/FAILED event to the approval_events chain (the grant
609
+ # is consumed here at PreToolUse and flips to CONSUMED, so PostToolUse
610
+ # cannot re-discover it via check_approval_grant).
607
611
  effective_command = result.modified_input.get("command", command) if result.modified_input else command
608
612
  state = create_pre_hook_state(
609
613
  tool_name=tool_name,
610
614
  command=effective_command,
611
615
  tier=str(result.tier),
612
616
  allowed=True,
617
+ consumed_approval_id=result.consumed_approval_id,
613
618
  )
614
619
  save_hook_state(state)
615
620
 
@@ -1003,6 +1008,26 @@ class ClaudeCodeAdapter(HookAdapter):
1003
1008
  "T3 grant confirmed (will be consumed at SubagentStop): %s", command[:80],
1004
1009
  )
1005
1010
 
1011
+ # Close the audit-log cycle for an APPROVED T3 command that just ran.
1012
+ # PreToolUse stashed the consumed grant's approval_id in HookState
1013
+ # when it matched (and consumed) the grant; append EXECUTED on a clean
1014
+ # exit, FAILED otherwise. This continues the approval_events hash chain
1015
+ # via the canonical store.record_event() helper -- the only authorized
1016
+ # writer for the chain (it routes through chain.insert_event(), which
1017
+ # links prev_hash -> this_hash before INSERT).
1018
+ if tool_name == "Bash":
1019
+ consumed_approval_id = (
1020
+ pre_state.metadata.get("consumed_approval_id") if pre_state else None
1021
+ )
1022
+ if consumed_approval_id:
1023
+ self._record_t3_outcome_event(
1024
+ consumed_approval_id,
1025
+ command=parameters.get("command", ""),
1026
+ success=success,
1027
+ exit_code=tool_result_data.exit_code,
1028
+ session_id=hook_data.get("session_id", ""),
1029
+ )
1030
+
1006
1031
  events = detect_critical_event(tool_name, parameters, output, success)
1007
1032
  if events:
1008
1033
  writer = SessionContextWriter()
@@ -1031,6 +1056,53 @@ class ClaudeCodeAdapter(HookAdapter):
1031
1056
 
1032
1057
  return HookResponse(output={}, exit_code=0)
1033
1058
 
1059
+ def _record_t3_outcome_event(
1060
+ self,
1061
+ approval_id: str,
1062
+ *,
1063
+ command: str,
1064
+ success: bool,
1065
+ exit_code: int,
1066
+ session_id: str = "",
1067
+ ) -> None:
1068
+ """Append an EXECUTED or FAILED event for an approved T3 command.
1069
+
1070
+ Closes the audit-log cycle: once a command runs under a consumed grant,
1071
+ the approval_events chain records whether it succeeded (EXECUTED) or
1072
+ failed (FAILED). Writes through gaia.approvals.store.record_event(), the
1073
+ canonical chain writer -- never a raw INSERT -- so prev_hash -> this_hash
1074
+ linkage is preserved and validate_chain() stays intact end to end.
1075
+
1076
+ Best-effort and non-fatal: the approval store lives in gaia.db and may be
1077
+ unavailable in some hook contexts; any failure is logged and swallowed so
1078
+ a chain-write hiccup never breaks tool execution.
1079
+ """
1080
+ event_type = "EXECUTED" if success else "FAILED"
1081
+ try:
1082
+ from gaia.approvals import store as _approval_store
1083
+
1084
+ payload = {
1085
+ "command": command,
1086
+ "exit_code": exit_code,
1087
+ "outcome": "success" if success else "failure",
1088
+ }
1089
+ _approval_store.record_event(
1090
+ approval_id,
1091
+ event_type,
1092
+ session_id=session_id or None,
1093
+ payload_json=json.dumps(payload, sort_keys=True, separators=(",", ":")),
1094
+ metadata_json=json.dumps({"source": "post_tool_use"}),
1095
+ )
1096
+ logger.info(
1097
+ "Recorded %s event for approval_id=%s (exit=%d)",
1098
+ event_type, approval_id[:16], exit_code,
1099
+ )
1100
+ except Exception as exc:
1101
+ logger.warning(
1102
+ "Failed to record %s event for approval_id=%s (non-fatal): %s",
1103
+ event_type, approval_id[:16], exc,
1104
+ )
1105
+
1034
1106
  # ------------------------------------------------------------------ #
1035
1107
  # _handle_ask_user_question_result: grant activation from user answer
1036
1108
  # ------------------------------------------------------------------ #
@@ -1045,19 +1117,15 @@ class ClaudeCodeAdapter(HookAdapter):
1045
1117
  2. Load the specific pending file by prefix (any session).
1046
1118
  3. Activate the grant under the CURRENT session.
1047
1119
 
1048
- Falls back to session-wide activation when no nonce is present in
1049
- the answer (backward compatibility with older approval labels).
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()``.
1050
1123
 
1051
1124
  Never blocks (no exceptions raised to caller).
1052
1125
  """
1053
1126
  from modules.security.approval_grants import (
1054
- activate_cross_session_pending,
1055
1127
  activate_db_pending_by_prefix,
1056
- activate_grants_for_session,
1057
- activate_pending_approval,
1058
1128
  extract_nonce_from_label,
1059
- get_pending_approvals_for_session,
1060
- load_pending_by_nonce_prefix,
1061
1129
  )
1062
1130
 
1063
1131
  session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_SESSION_ID", "")
@@ -1091,93 +1159,31 @@ class ClaudeCodeAdapter(HookAdapter):
1091
1159
  logger.info("AskUserQuestion: no session_id available, skipping grant activation")
1092
1160
  return
1093
1161
 
1094
- # Try nonce-targeted activation first: extract nonce from answer labels
1162
+ # Nonce-targeted activation: extract the nonce from answer labels.
1095
1163
  nonce_prefix = None
1096
1164
  for v in answers.values():
1097
1165
  nonce_prefix = extract_nonce_from_label(str(v))
1098
1166
  if nonce_prefix:
1099
1167
  break
1100
1168
 
1101
- if nonce_prefix:
1102
- # Nonce-targeted: load this specific pending regardless of session
1103
- pending_data = load_pending_by_nonce_prefix(nonce_prefix)
1104
- if pending_data:
1105
- pending_session = pending_data.get("session_id", "")
1106
- full_nonce = pending_data.get("nonce", "")
1107
-
1108
- if pending_session == session_id:
1109
- # Same session -- use standard activation
1110
- result = activate_pending_approval(
1111
- nonce=full_nonce,
1112
- session_id=session_id,
1113
- )
1114
- else:
1115
- # Cross session -- activate under current session
1116
- result = activate_cross_session_pending(
1117
- pending_data,
1118
- session_id=session_id,
1119
- )
1120
-
1121
- if result.success:
1122
- logger.info(
1123
- "AskUserQuestion nonce-targeted activation: prefix=%s, "
1124
- "pending_session=%s, current_session=%s, status=%s",
1125
- nonce_prefix, pending_session[:12], session_id[:12],
1126
- getattr(result.status, "value", str(result.status)),
1127
- )
1128
- return
1129
- else:
1130
- logger.warning(
1131
- "AskUserQuestion nonce-targeted activation failed: "
1132
- "prefix=%s, status=%s, reason=%s",
1133
- nonce_prefix,
1134
- getattr(result.status, "value", str(result.status)),
1135
- result.reason,
1136
- )
1137
- else:
1138
- # Filesystem pending not found -- try DB lookup (M2 bridge).
1139
- # Since M2, REQUESTED writes go to DB only; no pending-{nonce}.json
1140
- # is written to the filesystem any more.
1141
- logger.info(
1142
- "AskUserQuestion: nonce prefix %s found in label but no "
1143
- "matching pending file -- trying DB lookup (M2 bridge)",
1144
- nonce_prefix,
1145
- )
1146
- result = activate_db_pending_by_prefix(
1147
- nonce_prefix, current_session_id=session_id,
1148
- )
1149
- if result.success:
1150
- logger.info(
1151
- "AskUserQuestion DB-bridge activation: prefix=%s status=%s",
1152
- nonce_prefix,
1153
- getattr(result.status, "value", str(result.status)),
1154
- )
1155
- return
1156
- else:
1157
- logger.warning(
1158
- "AskUserQuestion DB-bridge activation failed: "
1159
- "prefix=%s status=%s reason=%s -- falling back to session-wide",
1160
- nonce_prefix,
1161
- getattr(result.status, "value", str(result.status)),
1162
- result.reason,
1163
- )
1164
- # Fall through to session-wide activation below
1165
- nonce_prefix = None
1166
-
1167
1169
  if not nonce_prefix:
1168
- # No nonce in label (or all targeted paths failed) -- fall back to
1169
- # session-wide activation for backward compatibility
1170
- pending = get_pending_approvals_for_session(session_id)
1171
- if not pending:
1172
- logger.info("AskUserQuestion: no pending grants for session %s", session_id)
1173
- return
1174
-
1175
- results = activate_grants_for_session(session_id)
1176
- activated = sum(1 for r in results if r.success)
1177
1170
  logger.info(
1178
- "AskUserQuestion session-wide activation: %d/%d pending grants for session %s",
1179
- activated, len(results), session_id,
1171
+ "AskUserQuestion: no nonce prefix in answer labels -- "
1172
+ "nothing to activate for session %s", session_id[:12],
1180
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
+ )
1181
1187
 
1182
1188
  except Exception as e:
1183
1189
  logger.error("Error in _handle_ask_user_question_result: %s", e, exc_info=True)