@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +13 -0
- package/bin/README.md +6 -1
- package/bin/cli/approvals.py +486 -474
- 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 +92 -86
- package/dist/gaia-ops/hooks/modules/agents/handoff_persister.py +13 -2
- 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/modules/tools/bash_validator.py +19 -0
- 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 +50 -14
- package/dist/gaia-ops/skills/agent-approval-protocol/reference.md +16 -9
- package/dist/gaia-ops/skills/agent-protocol/examples.md +12 -1
- package/dist/gaia-ops/skills/gaia-patterns/reference.md +2 -2
- package/dist/gaia-ops/skills/orchestrator-present-approval/SKILL.md +69 -22
- package/dist/gaia-ops/skills/orchestrator-present-approval/reference.md +16 -3
- package/dist/gaia-ops/skills/orchestrator-present-approval/template.md +20 -14
- package/dist/gaia-ops/skills/pending-approvals/SKILL.md +16 -11
- package/dist/gaia-ops/skills/subagent-request-approval/SKILL.md +28 -3
- package/dist/gaia-ops/skills/subagent-request-approval/reference.md +34 -8
- 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 +92 -86
- package/dist/gaia-security/hooks/modules/agents/handoff_persister.py +13 -2
- 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/modules/tools/bash_validator.py +19 -0
- package/dist/gaia-security/hooks/user_prompt_submit.py +20 -0
- package/gaia/approvals/__init__.py +2 -1
- package/gaia/approvals/store.py +165 -15
- package/gaia/store/schema.sql +38 -1
- package/gaia/store/writer.py +400 -0
- package/hooks/adapters/claude_code.py +92 -86
- package/hooks/elicitation_result.py +20 -75
- package/hooks/modules/agents/handoff_persister.py +13 -2
- 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/modules/tools/bash_validator.py +19 -0
- 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 +50 -14
- package/skills/agent-approval-protocol/reference.md +16 -9
- package/skills/agent-protocol/examples.md +12 -1
- package/skills/gaia-patterns/reference.md +2 -2
- package/skills/orchestrator-present-approval/SKILL.md +69 -22
- package/skills/orchestrator-present-approval/reference.md +16 -3
- package/skills/orchestrator-present-approval/template.md +20 -14
- package/skills/pending-approvals/SKILL.md +16 -11
- package/skills/subagent-request-approval/SKILL.md +28 -3
- package/skills/subagent-request-approval/reference.md +34 -8
- 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/gaia/approvals/revert.py +0 -282
- 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
|
@@ -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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
20
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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`
|
|
146
|
-
|
|
147
|
-
|
|
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 |
|
|
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
|
-
|
|
41
|
-
./validate.sh #
|
|
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 |
|
|
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 |
|
|
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
|
-
|
|
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",
|
|
@@ -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
|
-
|
|
1049
|
-
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()``.
|
|
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
|
-
#
|
|
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
|
|
1179
|
-
|
|
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)
|