@jaguilar87/gaia-ops 4.4.0 → 4.5.0

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.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "gaia-security",
10
10
  "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.",
11
- "version": "4.4.0",
11
+ "version": "4.5.0",
12
12
  "source": "./dist/gaia-security"
13
13
  }
14
14
  ]
@@ -1,15 +1,24 @@
1
1
  {
2
2
  "name": "gaia-ops",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "description": "Security-first orchestrator with specialized agents, hooks, and governance for AI coding",
5
5
  "author": {
6
6
  "name": "jaguilar87"
7
7
  },
8
8
  "repository": "https://github.com/metraton/gaia-ops",
9
9
  "license": "MIT",
10
- "keywords": ["security", "devops", "orchestrator", "governance"],
10
+ "keywords": [
11
+ "security",
12
+ "devops",
13
+ "orchestrator",
14
+ "governance"
15
+ ],
11
16
  "engines": {
12
17
  "claude-code": ">=2.1.0"
13
18
  },
14
- "categories": ["devops", "security", "orchestration"]
19
+ "categories": [
20
+ "devops",
21
+ "security",
22
+ "orchestration"
23
+ ]
15
24
  }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,40 @@ All notable changes to the gaia-ops orchestration system are documented in this
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.5.0] - 2026-03-24
9
+
10
+ ### Settings Architecture Redesign + Multi-Cloud Security
11
+
12
+ Unified approach for permissions across NPM and plugin installation modes. Permissions now live in `settings.local.json` (union merge, preserves user config). `settings.json` contains only hooks.
13
+
14
+ #### Added
15
+ - **Azure deny rules** — 39 rules covering resource groups, networking, AKS, Key Vault, CosmosDB, Service Bus, and more
16
+ - **Generic wildcard deny rules** — 20 rules that catch all present and future cloud services (`aws * delete-*`, `az * delete`, `gcloud * delete`, etc.)
17
+ - **Indirect execution detection** — Catches `bash -c`, `eval`, `python3 -c`, `node -e`, `ruby -e`, `perl -e` wrappers that bypass regex patterns
18
+ - **Managed settings template** — `templates/managed-settings.template.json` for enterprise deployment via Claude.ai Admin Console
19
+ - **`updateLocalPermissions()`** in `gaia-update.js` — NPM postinstall now merges permissions into `settings.local.json` (same approach as plugin SessionStart)
20
+ - **Plugin mode detection via `plugin.json`** — `plugin_setup.py` and `plugin_mode.py` now read `.claude-plugin/plugin.json` for reliable name/version/mode detection with `--plugin-dir`
21
+ - **First-run welcome message** — `user_prompt_submit.py` detects first run and injects a welcome explaining that restart is needed to activate permissions
22
+
23
+ #### Changed
24
+ - **`settings.template.json`** — Removed permissions block; template now contains only hooks + environment
25
+ - **`_DENY_RULES` centralized in Python** — Single source of truth in `plugin_setup.py`, shared by both OPS and SECURITY modes
26
+ - **T3 approval flow** — All T3 mutative operations now use native `ask` dialog (both ops and security mode). Nonce workflow removed from direct conversation; kept for subagent use via skills.
27
+ - **`approval_messages.py`** — Simplified T3 block message to minimal data (tier + nonce). Workflow instructions live in skills, not hook messages.
28
+ - **`pre_tool_use.py`** — Simplified: passes through `block_response` from `bash_validator` directly, no more mode-specific branching
29
+ - **`bash_validator.py`** — T3 mutative returns `ask` response directly (no nonce generation, no pending files)
30
+ - **`session_start.py`** — Uses `mark_done=False` so `user_prompt_submit.py` can detect first-run and show welcome before marking initialized
31
+ - **`gaia-update.js` registry path** — Fixed to write `plugin-registry.json` in `.claude/` (same path Python hooks expect)
32
+ - **`gaia-doctor.js`** — Now checks permissions in `settings.local.json` (not just `settings.json`). Updated agent and config file lists.
33
+ - **`gaia-update.js` health check** — Updated config files (`surface-routing.json`) and agent list (`gaia-system.md`, `speckit-planner.md`)
34
+
35
+ #### Fixed
36
+ - **Registry path mismatch** — `gaia-update.js` wrote to `.claude/project-context/`, Python read from `.claude/`. Now both use `.claude/`.
37
+ - **Orphaned nonce files** — `bash_validator` no longer writes pending approval files for `ask` responses
38
+ - **Plugin mode detection** — `--plugin-dir` now correctly detects `gaia-ops` vs `gaia-security` via `plugin.json` instead of path parsing
39
+ - **First-run welcome race condition** — `SessionStart` no longer marks initialized; `UserPromptSubmit` marks after showing welcome
40
+ - **`_build_welcome()` framing** — Rewritten to explain WHY the user needs to restart (permissions not active yet), making Claude naturally relay the message
41
+
8
42
  ## [4.4.0-rc.5] - 2026-03-19
9
43
 
10
44
  ### Identity Redesign
package/README.md CHANGED
@@ -14,14 +14,17 @@ Multi-agent DevOps system that classifies every operation by risk, routes work t
14
14
 
15
15
  ### Features
16
16
 
17
- - **Multi-cloud support** - GCP, AWS
18
- - **6 agents** - terraform-architect, gitops-operator, cloud-troubleshooter, devops-developer, speckit-planner, gaia (meta-agent)
17
+ - **Multi-cloud support** - GCP, AWS, Azure
18
+ - **6 agents** - terraform-architect, gitops-operator, cloud-troubleshooter, devops-developer, speckit-planner, gaia-system (meta-agent)
19
19
  - **Contracts as SSOT** - Cloud-agnostic base contracts with per-cloud extensions (GCP, AWS)
20
20
  - **Dynamic identity** - Orchestrator identity injected by UserPromptSubmit hook; skills loaded on-demand
21
- - **Approval gates** for T3 operations via nonce-based workflow
21
+ - **Dual-barrier security** - Settings deny rules (Claude Code native) + hook-level blocking (inalterable via symlink)
22
+ - **Indirect execution detection** - Catches `bash -c`, `eval`, `python -c` wrappers that bypass regex patterns
23
+ - **Approval gates** for T3 operations via native `ask` dialog
22
24
  - **Git commit validation** with Conventional Commits
23
- - **20 skills** - Injected procedural knowledge modules for agents
25
+ - **21 skills** - Injected procedural knowledge modules for agents
24
26
  - **Plugin + npm** - Distributable as Claude Code native plugin or npm package
27
+ - **Enterprise ready** - Managed settings template for organization-wide deployment
25
28
 
26
29
  ## Installation
27
30
 
@@ -56,12 +59,24 @@ gaia-scan
56
59
 
57
60
  This will:
58
61
  1. Auto-detect your project structure (GitOps, Terraform, AppServices)
59
- 2. Install Claude Code if not present
60
- 3. Create `.claude/` directory with symlinks to this package
61
- 4. Generate `project-context.json` and `settings.json`
62
+ 2. Create `.claude/` directory with symlinks to this package
63
+ 3. Generate `project-context.json`
64
+ 4. Create `settings.json` with hooks only (no permissions in settings.json)
65
+ 5. Merge deny rules + allow permissions into `settings.local.json` (preserves existing user config)
62
66
 
63
67
  No `CLAUDE.md` is generated -- orchestrator identity is injected dynamically by the UserPromptSubmit hook.
64
68
 
69
+ ### Settings Architecture
70
+
71
+ Gaia-Ops separates hooks from permissions:
72
+
73
+ | File | Content | Strategy |
74
+ |------|---------|----------|
75
+ | `settings.json` | Hooks only (9 hook types) | Overwritten from template on each update |
76
+ | `settings.local.json` | Permissions (allow + deny rules) | Union merge — never removes user config |
77
+
78
+ This ensures your personal customizations (MCP servers, extra permissions) survive updates.
79
+
65
80
  ### Manual Installation
66
81
 
67
82
  ```bash
@@ -100,17 +115,34 @@ npx gaia-skills-diagnose
100
115
  npx gaia-skills-diagnose --run-tests
101
116
  ```
102
117
 
118
+ ## Security
119
+
120
+ Gaia-Ops enforces a 6-layer security pipeline:
121
+
122
+ | Layer | Mechanism | Bypassable? |
123
+ |-------|-----------|-------------|
124
+ | Indirect execution detection | `bash -c`, `eval`, `python -c` wrappers | No (hook-level) |
125
+ | Blocked commands (regex) | 85+ regex patterns | No (symlink to npm package) |
126
+ | Blocked commands (semantic) | 70+ ordered-token rules | No (symlink to npm package) |
127
+ | Cloud pipe validator | Credential piping detection | No (hook-level) |
128
+ | Mutative verb detection | `ask` dialog for state-changing ops | User approves via native dialog |
129
+ | Settings deny rules | 147 deny rules in `settings.local.json` | Self-healing (restored each session) |
130
+
131
+ ### Enterprise Deployment
132
+
133
+ For organization-wide enforcement, deploy `templates/managed-settings.template.json` as a managed settings policy via Claude.ai Admin Console. Managed settings have the highest precedence and cannot be overridden.
134
+
103
135
  ## Project Structure
104
136
 
105
137
  ```
106
138
  node_modules/@jaguilar87/gaia-ops/
107
139
  ├── agents/ # Agent definitions (6 agents)
108
- ├── skills/ # Skill modules (20 skills)
140
+ ├── skills/ # Skill modules (21 skills)
109
141
  ├── tools/ # Orchestration tools
110
142
  ├── hooks/ # Claude Code hooks (modular architecture)
111
143
  ├── commands/ # Slash commands (5 speckit + scan-project)
112
144
  ├── config/ # Configuration (contracts, git standards, rules)
113
- ├── templates/ # Installation templates (settings, governance)
145
+ ├── templates/ # Installation templates (settings, governance, managed-settings)
114
146
  ├── speckit/ # Spec-Kit framework (templates)
115
147
  ├── bin/ # CLI utilities (11 scripts)
116
148
  └── tests/ # Test suite
@@ -133,7 +165,7 @@ This package follows [Semantic Versioning](https://semver.org/):
133
165
  - **MINOR:** New features
134
166
  - **PATCH:** Bug fixes
135
167
 
136
- Current version: **4.4.0-rc.5**
168
+ Current version: **4.5.0**
137
169
 
138
170
  See [CHANGELOG.md](./CHANGELOG.md) for version history.
139
171
 
@@ -169,7 +201,7 @@ git clone git@bitbucket.org:yourorg/your-project-context.git project-context
169
201
 
170
202
  - **Issues:** [GitHub Issues](https://github.com/metraton/gaia-ops/issues)
171
203
  - **Repository:** [github.com/metraton/gaia-ops](https://github.com/metraton/gaia-ops)
172
- - **Author:** Jorge Aguilar <jaguilar1897@gmail.com>
204
+ - **Author:** Jorge Aguilar <jorge.aguilar88@gmail.com>
173
205
 
174
206
  ## License
175
207
 
package/bin/README.md CHANGED
@@ -27,7 +27,7 @@ Configure symlinks Remove files
27
27
  | Script | Description |
28
28
  |--------|-------------|
29
29
  | `gaia-scan` | Project scanner and installer (Python) |
30
- | `gaia-update.js` | Configuration updater (postinstall hook) |
30
+ | `gaia-update.js` | Configuration updater (postinstall hook) — updates hooks template, merges permissions into settings.local.json, ensures plugin-registry |
31
31
 
32
32
  ### Diagnostics and Monitoring
33
33
 
@@ -146,4 +146,4 @@ npx gaia-scan --non-interactive
146
146
 
147
147
  ---
148
148
 
149
- **Version:** 4.4.0-rc.5 | **Updated:** 2026-03-19 | **Scripts:** 11
149
+ **Version:** 4.5.0 | **Updated:** 2026-03-24 | **Scripts:** 11
@@ -102,17 +102,30 @@ async function checkSettingsJson() {
102
102
  if (!hookTypes.includes('PostToolUse')) issues.push('Missing PostToolUse hook');
103
103
  }
104
104
 
105
- // Check permissions exist
106
- if (!data.permissions) {
107
- issues.push('No permissions configured');
105
+ // Check permissions — now live in settings.local.json (not settings.json)
106
+ const localPath = join(CWD, '.claude', 'settings.local.json');
107
+ let permCount = 0;
108
+ if (existsSync(localPath)) {
109
+ try {
110
+ const localData = JSON.parse(await fs.readFile(localPath, 'utf-8'));
111
+ if (localData.permissions) {
112
+ permCount = Object.values(localData.permissions).flat().length;
113
+ }
114
+ } catch { /* ignore parse errors */ }
115
+ }
116
+ // Also count permissions in settings.json (legacy installs)
117
+ if (data.permissions) {
118
+ permCount += Object.values(data.permissions).flat().length;
119
+ }
120
+ if (permCount === 0) {
121
+ issues.push('No permissions configured (check settings.local.json)');
108
122
  }
109
123
 
110
124
  if (issues.length > 0) {
111
- return { name: 'settings.json', ok: false, detail: issues.join('; '), fix: 'Run gaia-scan' };
125
+ return { name: 'settings.json', ok: false, detail: issues.join('; '), fix: 'Run gaia-scan or npx gaia-update' };
112
126
  }
113
127
 
114
128
  const hookCount = data.hooks ? Object.keys(data.hooks).length : 0;
115
- const permCount = data.permissions ? Object.values(data.permissions).flat().length : 0;
116
129
  return { name: 'settings.json', ok: true, detail: `${hookCount} hook types, ${permCount} rules` };
117
130
  } catch {
118
131
  return { name: 'settings.json', ok: false, detail: 'Invalid JSON', fix: 'Delete and run gaia-scan' };
package/bin/gaia-scan.py CHANGED
@@ -238,8 +238,11 @@ def _mode_fresh(project_root: Path, scan_config: ScanConfig, args) -> int:
238
238
 
239
239
  # Step 4: INSTALL (automatic, no prompts)
240
240
  skip_claude = getattr(args, "skip_claude_install", False)
241
+ npm_postinstall = getattr(args, "npm_postinstall", False)
241
242
  ensure_claude_code(skip_install=skip_claude)
242
- ensure_gaia_ops_package(project_root)
243
+ if not npm_postinstall:
244
+ # Skip when called from npm postinstall to avoid re-entrance
245
+ ensure_gaia_ops_package(project_root)
243
246
  create_claude_directory(project_root)
244
247
  copy_claude_md(project_root)
245
248
  copy_settings_json(project_root)
@@ -496,6 +499,13 @@ def main(argv: list = None) -> int:
496
499
  dest="skip_claude_install",
497
500
  help="Skip Claude Code CLI installation",
498
501
  )
502
+ parser.add_argument(
503
+ "--npm-postinstall",
504
+ action="store_true",
505
+ default=False,
506
+ dest="npm_postinstall",
507
+ help="Called from npm postinstall: skip Claude Code install and npm package install to avoid re-entrance",
508
+ )
499
509
 
500
510
  args = parser.parse_args(argv)
501
511
 
@@ -544,11 +554,19 @@ def main(argv: list = None) -> int:
544
554
  print(json.dumps(result), file=sys.stdout)
545
555
  return 0
546
556
 
557
+ # --npm-postinstall implies --skip-claude-install and skips ensure_gaia_ops_package
558
+ if args.npm_postinstall:
559
+ args.skip_claude_install = True
560
+
547
561
  # Mode selection
548
562
  # --json or --scan-only: scan-only mode
549
563
  if args.json or args.scan_only:
550
564
  return _mode_scan_only(project_root, scan_config, args)
551
565
 
566
+ # --npm-postinstall: fresh install mode with re-entrance protection
567
+ if args.npm_postinstall:
568
+ return _mode_fresh(project_root, scan_config, args)
569
+
552
570
  # Detect mode based on .claude/ existence
553
571
  claude_dir = project_root / ".claude"
554
572
  if claude_dir.is_dir():
@@ -7,13 +7,19 @@
7
7
  * Also available as: npx gaia-update
8
8
  *
9
9
  * Behavior:
10
- * - First-time install (.claude/ doesn't exist): skip silently (gaia-scan handles it)
10
+ * - First-time install (.claude/ doesn't exist):
11
+ * 1. Check Python 3 is available
12
+ * 2. Run gaia-scan --npm-postinstall to create .claude/, symlinks, settings, project-context
13
+ * 3. Create plugin-registry.json
14
+ * 4. Merge permissions into settings.local.json
15
+ * 5. Fall through to verification
11
16
  * - Update (.claude/ exists):
12
17
  * 1. Show version transition (previous → current)
13
- * 2. settings.json: REPLACE from template (template is source of truth)
14
- * 3. Symlinks: recreate if missing, fix broken ones
15
- * 4. Verify: hooks, python, project-context, config files
16
- * 5. Report: summary with any issues found
18
+ * 2. settings.json: REPLACE from template (hooks only no permissions)
19
+ * 3. Merge permissions into settings.local.json (union, preserves user config)
20
+ * 4. Symlinks: recreate if missing, fix broken ones
21
+ * 5. Verify: hooks, python, project-context, config files
22
+ * 6. Report: summary with any issues found
17
23
  *
18
24
  * Usage:
19
25
  * npm update @jaguilar87/gaia-ops # Automatic via postinstall
@@ -82,9 +88,9 @@ async function updateSettingsJson() {
82
88
  return false;
83
89
  }
84
90
 
85
- // Always replace from template -- template is the source of truth
91
+ // Always replace from template -- template is the source of truth (hooks only)
86
92
  await fs.copyFile(templatePath, settingsPath);
87
- spinner.succeed('settings.json updated from template');
93
+ spinner.succeed('settings.json updated from template (hooks)');
88
94
  return true;
89
95
  } catch (error) {
90
96
  spinner.fail(`settings.json: ${error.message}`);
@@ -92,6 +98,114 @@ async function updateSettingsJson() {
92
98
  }
93
99
  }
94
100
 
101
+ async function updateLocalPermissions() {
102
+ const spinner = ora('Merging permissions into settings.local.json...').start();
103
+ try {
104
+ const claudeDir = join(CWD, '.claude');
105
+ const localPath = join(claudeDir, 'settings.local.json');
106
+
107
+ if (!existsSync(claudeDir)) {
108
+ spinner.info('Skipped (.claude/ not found)');
109
+ return false;
110
+ }
111
+
112
+ // Load permissions from plugin_setup.py — the single source of truth.
113
+ // We use ast.literal_eval to extract the constants without importing
114
+ // the module (which has relative imports that fail standalone).
115
+ let gaiaPerms;
116
+ try {
117
+ const setupPath = join(__dirname, '..', 'hooks', 'modules', 'core', 'plugin_setup.py');
118
+ const { stdout } = await execAsync(
119
+ `python3 -c "
120
+ import ast, json, re
121
+
122
+ source = open('${setupPath.replace(/'/g, "\\'")}').read()
123
+
124
+ # Extract _DENY_RULES list
125
+ deny_match = re.search(r'^_DENY_RULES\\s*=\\s*\\[', source, re.MULTILINE)
126
+ if deny_match:
127
+ bracket_start = deny_match.start() + source[deny_match.start():].index('[')
128
+ depth, i = 0, bracket_start
129
+ for i, ch in enumerate(source[bracket_start:], bracket_start):
130
+ if ch == '[': depth += 1
131
+ elif ch == ']': depth -= 1
132
+ if depth == 0: break
133
+ deny_rules = ast.literal_eval(source[bracket_start:i+1])
134
+ else:
135
+ deny_rules = []
136
+
137
+ # Extract OPS_PERMISSIONS allow list
138
+ ops_match = re.search(r'^OPS_PERMISSIONS\\s*=', source, re.MULTILINE)
139
+ if ops_match:
140
+ bracket_start = source.index('{', ops_match.start())
141
+ depth, i = 0, bracket_start
142
+ for i, ch in enumerate(source[bracket_start:], bracket_start):
143
+ if ch == '{': depth += 1
144
+ elif ch == '}': depth -= 1
145
+ if depth == 0: break
146
+ # Replace _DENY_RULES reference with actual list for eval
147
+ ops_str = source[bracket_start:i+1].replace('_DENY_RULES', json.dumps(deny_rules))
148
+ ops_perms = ast.literal_eval(ops_str)
149
+ else:
150
+ ops_perms = {'permissions': {'allow': [], 'deny': deny_rules, 'ask': []}}
151
+
152
+ print(json.dumps(ops_perms))
153
+ "`,
154
+ { timeout: 10000 }
155
+ );
156
+ gaiaPerms = JSON.parse(stdout.trim());
157
+ } catch (pyError) {
158
+ spinner.warn(`Could not load permissions from Python — ${pyError.message || 'unknown error'}`);
159
+ return false;
160
+ }
161
+
162
+ const ourAllow = new Set(gaiaPerms.permissions.allow || []);
163
+ const ourDeny = new Set(gaiaPerms.permissions.deny || []);
164
+
165
+ // Load existing settings.local.json — preserve everything (enabledPlugins, MCP servers, etc.)
166
+ let existing = {};
167
+ if (existsSync(localPath)) {
168
+ try {
169
+ existing = JSON.parse(await fs.readFile(localPath, 'utf-8'));
170
+ } catch {
171
+ existing = {};
172
+ }
173
+ }
174
+
175
+ const perms = existing.permissions || {};
176
+ const currentAllow = new Set(perms.allow || []);
177
+ const currentDeny = new Set(perms.deny || []);
178
+
179
+ // Union merge — add ours without removing user's
180
+ const mergedAllow = [...new Set([...currentAllow, ...ourAllow])].sort();
181
+ const mergedDeny = [...new Set([...currentDeny, ...ourDeny])].sort();
182
+
183
+ // Check if anything changed
184
+ const allowChanged = mergedAllow.length !== currentAllow.size
185
+ || mergedAllow.some(r => !currentAllow.has(r));
186
+ const denyChanged = mergedDeny.length !== currentDeny.size
187
+ || mergedDeny.some(r => !currentDeny.has(r));
188
+
189
+ if (!allowChanged && !denyChanged) {
190
+ spinner.succeed('settings.local.json permissions already up to date');
191
+ return false;
192
+ }
193
+
194
+ // Update only permissions, preserve everything else
195
+ existing.permissions = existing.permissions || {};
196
+ existing.permissions.allow = mergedAllow;
197
+ existing.permissions.deny = mergedDeny;
198
+ existing.permissions.ask = existing.permissions.ask || [];
199
+
200
+ await fs.writeFile(localPath, JSON.stringify(existing, null, 2) + '\n');
201
+ spinner.succeed('settings.local.json permissions merged');
202
+ return true;
203
+ } catch (error) {
204
+ spinner.fail(`settings.local.json: ${error.message}`);
205
+ return false;
206
+ }
207
+ }
208
+
95
209
  async function updateSymlinks() {
96
210
  const spinner = ora('Checking symlinks...').start();
97
211
  try {
@@ -206,7 +320,7 @@ async function runVerification() {
206
320
  }
207
321
 
208
322
  // 4. Config files accessible
209
- const configFiles = ['classification-rules.json', 'git_standards.json', 'universal-rules.json'];
323
+ const configFiles = ['git_standards.json', 'universal-rules.json', 'surface-routing.json'];
210
324
  for (const cfg of configFiles) {
211
325
  const path = join(CWD, '.claude', 'config', cfg);
212
326
  if (existsSync(path)) {
@@ -218,7 +332,7 @@ async function runVerification() {
218
332
  }
219
333
 
220
334
  // 5. Agent definitions accessible
221
- const agentFiles = ['terraform-architect.md', 'gitops-operator.md', 'cloud-troubleshooter.md', 'devops-developer.md', 'gaia.md'];
335
+ const agentFiles = ['terraform-architect.md', 'gitops-operator.md', 'cloud-troubleshooter.md', 'devops-developer.md', 'gaia-system.md', 'speckit-planner.md'];
222
336
  let agentsOk = 0;
223
337
  for (const agent of agentFiles) {
224
338
  if (existsSync(join(CWD, '.claude', 'agents', agent))) agentsOk++;
@@ -256,48 +370,102 @@ async function runVerification() {
256
370
  // Main
257
371
  // ============================================================================
258
372
 
373
+ async function runFreshInstall() {
374
+ const packageDir = join(__dirname, '..');
375
+ const scanScript = join(packageDir, 'bin', 'gaia-scan.py');
376
+ const { current } = await detectVersions();
377
+
378
+ console.log(chalk.cyan(`\n gaia-ops ${chalk.green(current)} — fresh install\n`));
379
+
380
+ // 1. Check Python 3 is available
381
+ const spinner = ora('Checking Python 3...').start();
382
+ try {
383
+ await execAsync('python3 --version', { timeout: 5000 });
384
+ spinner.succeed('Python 3 found');
385
+ } catch {
386
+ spinner.warn('Python 3 not found — skipping project setup');
387
+ console.log(chalk.gray(' Install Python 3.9+ and run: npx gaia-scan\n'));
388
+ return;
389
+ }
390
+
391
+ // 2. Run gaia-scan --npm-postinstall
392
+ const scanSpinner = ora('Running gaia-scan...').start();
393
+ try {
394
+ const { stdout, stderr } = await execAsync(
395
+ `python3 "${scanScript}" --npm-postinstall --root "${CWD}"`,
396
+ { timeout: 60000 }
397
+ );
398
+ scanSpinner.succeed('Project scanned and configured');
399
+ if (VERBOSE && stdout) console.log(chalk.gray(stdout));
400
+ if (VERBOSE && stderr) console.log(chalk.yellow(stderr));
401
+ } catch (error) {
402
+ scanSpinner.warn('gaia-scan encountered issues (non-fatal)');
403
+ if (VERBOSE && error.stderr) console.log(chalk.gray(error.stderr));
404
+ }
405
+
406
+ // 3. Create plugin-registry.json (in .claude/, same path Python hooks expect)
407
+ try {
408
+ const claudeDirPath = join(CWD, '.claude');
409
+ if (!existsSync(claudeDirPath)) {
410
+ await fs.mkdir(claudeDirPath, { recursive: true });
411
+ }
412
+ const registryPath = join(claudeDirPath, 'plugin-registry.json');
413
+ const registry = {
414
+ installed: [{ name: 'gaia-ops', version: current || 'unknown' }],
415
+ source: 'npm-postinstall',
416
+ };
417
+ await fs.writeFile(registryPath, JSON.stringify(registry, null, 2) + '\n');
418
+ } catch {
419
+ // Non-fatal — plugin-registry is a convenience, not critical
420
+ }
421
+
422
+ // 4. Merge permissions into settings.local.json (same approach as plugin mode)
423
+ await updateLocalPermissions();
424
+ }
425
+
259
426
  async function main() {
260
427
  const claudeDir = join(CWD, '.claude');
261
428
  const isUpdate = existsSync(claudeDir);
262
429
 
263
430
  if (!isUpdate) {
264
- // First-time install — gaia-scan handles everything
265
- process.exit(0);
431
+ // First-time install — run gaia-scan to bootstrap everything
432
+ await runFreshInstall();
433
+ } else {
434
+ // Version info
435
+ const { previous, current } = await detectVersions();
436
+ const versionLine = previous && previous !== current
437
+ ? `${chalk.gray(previous)} → ${chalk.green(current)}`
438
+ : chalk.green(current);
439
+
440
+ console.log(chalk.cyan(`\n gaia-ops update ${versionLine}\n`));
441
+
442
+ // Step 1-3: Update files
443
+ await updateSettingsJson();
444
+ await updateLocalPermissions();
445
+ await updateSymlinks();
266
446
  }
267
447
 
268
- // Version info
269
- const { previous, current } = await detectVersions();
270
- const versionLine = previous && previous !== current
271
- ? `${chalk.gray(previous)} → ${chalk.green(current)}`
272
- : chalk.green(current);
273
-
274
- console.log(chalk.cyan(`\n gaia-ops update ${versionLine}\n`));
275
-
276
- // Step 1-2: Update files
277
- const settingsUpdated = await updateSettingsJson();
278
- const { updated: symlinksUpdated, fixed: symlinksFix } = await updateSymlinks();
448
+ // Ensure plugin-registry.json exists in .claude/ (both fresh and update)
449
+ try {
450
+ const registryPath = join(CWD, '.claude', 'plugin-registry.json');
451
+ if (!existsSync(registryPath)) {
452
+ const { current } = await detectVersions();
453
+ const registry = {
454
+ installed: [{ name: 'gaia-ops', version: current || 'unknown' }],
455
+ source: 'npm-postinstall',
456
+ };
457
+ await fs.writeFile(registryPath, JSON.stringify(registry, null, 2) + '\n');
458
+ }
459
+ } catch { /* non-fatal */ }
279
460
 
280
- // Step 3: Verify
461
+ // Verify (runs for both fresh install and update)
281
462
  const { issues, passed, total } = await runVerification();
282
463
 
283
- // Summary
284
- const changes = [settingsUpdated, symlinksUpdated].filter(Boolean).length;
285
-
286
464
  console.log('');
287
- if (changes > 0 || issues.length > 0) {
288
- // Changes summary
289
- if (changes > 0) {
290
- console.log(chalk.green(` ${changes} file(s) updated`));
291
- if (settingsUpdated) console.log(chalk.gray(' settings.json: replaced from template'));
292
- if (symlinksFix > 0) console.log(chalk.gray(` ${symlinksFix} symlink(s) fixed`));
293
- }
294
-
295
- // Issues
296
- if (issues.length > 0) {
297
- console.log(chalk.yellow(`\n ${issues.length} issue(s) found:`));
298
- for (const issue of issues) {
299
- console.log(chalk.yellow(` - ${issue}`));
300
- }
465
+ if (issues.length > 0) {
466
+ console.log(chalk.yellow(` ${issues.length} issue(s) found:`));
467
+ for (const issue of issues) {
468
+ console.log(chalk.yellow(` - ${issue}`));
301
469
  }
302
470
  } else {
303
471
  console.log(chalk.green(' Everything up to date'));
@@ -0,0 +1,41 @@
1
+ #!/bin/sh
2
+ #
3
+ # commit-msg hook: strip Claude Code attribution footers from commit messages.
4
+ #
5
+ # This hook runs at the git level, catching ALL commits regardless of whether
6
+ # they originate from Claude Code's Bash tool, a subagent, or the user's terminal.
7
+ #
8
+ # Patterns match those in hooks/modules/tools/bash_validator.py _strip_claude_footers()
9
+ # to maintain a single source of truth for what constitutes a forbidden footer.
10
+ #
11
+ # Installation:
12
+ # cp git-hooks/commit-msg .git/hooks/commit-msg
13
+ # chmod +x .git/hooks/commit-msg
14
+ #
15
+ # Or via gaia-init (automatic).
16
+
17
+ COMMIT_MSG_FILE="$1"
18
+
19
+ if [ ! -f "${COMMIT_MSG_FILE}" ]; then
20
+ exit 0
21
+ fi
22
+
23
+ # Create a temp file for the cleaned message
24
+ TEMP_FILE=$(mktemp)
25
+ trap 'rm -f "${TEMP_FILE}"' EXIT
26
+
27
+ # Read the commit message and strip forbidden footer lines:
28
+ # - Co-Authored-By: containing "Claude" (any case)
29
+ # - "Generated with Claude Code" or "[Claude Code]" (any case)
30
+ # - Emoji-prefixed "Generated with" lines
31
+ sed -E \
32
+ -e '/^[[:space:]]*[Cc][Oo]-[Aa][Uu][Tt][Hh][Oo][Rr][Ee][Dd]-[Bb][Yy]:.*[Cc][Ll][Aa][Uu][Dd][Ee]/d' \
33
+ -e '/^[[:space:]]*[Gg][Ee][Nn][Ee][Rr][Aa][Tt][Ee][Dd] [Ww][Ii][Tt][Hh].*[Cc][Ll][Aa][Uu][Dd][Ee] [Cc][Oo][Dd][Ee]/d' \
34
+ -e '/^[[:space:]]*..?[[:space:]]*[Gg][Ee][Nn][Ee][Rr][Aa][Tt][Ee][Dd] [Ww][Ii][Tt][Hh]/d' \
35
+ "${COMMIT_MSG_FILE}" > "${TEMP_FILE}"
36
+
37
+ # Remove trailing blank lines (collapse to single trailing newline)
38
+ # This prevents empty trailers after stripping
39
+ sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' "${TEMP_FILE}" > "${COMMIT_MSG_FILE}"
40
+
41
+ exit 0
package/hooks/README.md CHANGED
@@ -49,7 +49,8 @@ Command executes
49
49
  | **T0** | Read-only (get, list) | No | pre_tool_use |
50
50
  | **T1** | Local validation (validate, lint) | No | pre_tool_use |
51
51
  | **T2** | Simulation (plan, diff) | No | pre_tool_use |
52
- | **T3** | Execution (apply, delete) | **Yes** | pre_tool_use |
52
+ | **T3** | Execution (apply, delete) | **Yes** (native `ask` dialog) | pre_tool_use |
53
+ | **T3-blocked** | Irreversible (delete-vpc, drop db) | **Permanently blocked** | pre_tool_use (exit 2) |
53
54
 
54
55
  ## File Structure
55
56
 
@@ -70,6 +71,6 @@ hooks/
70
71
 
71
72
  ---
72
73
 
73
- **Version:** 4.4.0-rc.5
74
- **Last updated:** 2026-03-19
75
- **Total hooks:** 8 hook scripts (4 primary + 4 event handlers)
74
+ **Version:** 4.5.0
75
+ **Last updated:** 2026-03-24
76
+ **Total hooks:** 9 hook scripts (4 primary + 4 event handlers + post_compact)
@@ -173,11 +173,12 @@ All security rules (blocked patterns, mutative verbs, tiers) are hardcoded in th
173
173
 
174
174
  ### Validation Order (Defense-in-Depth)
175
175
  bash_validator checks commands in this order (short-circuit on first match):
176
+ 0. **Indirect execution detection** — `bash -c`, `eval`, `python -c` etc. → ask or block
176
177
  1. **Blocked commands** (blocked_commands.py) — permanently denied patterns, exit 2
177
178
  2. **Claude footer stripping** — transparent via updatedInput
178
179
  3. **Commit message validation** — conventional commits enforcement
179
180
  4. **Cloud pipe/redirect/chain check** (cloud_pipe_validator.py) — corrective deny
180
- 5. **Mutative verbs** (mutative_verbs.py) — CLI-agnostic verb detector, nonce-based deny
181
+ 5. **Mutative verbs** (mutative_verbs.py) — CLI-agnostic verb detector, native `ask` dialog
181
182
  6. **GitOps validation** (gitops_validator.py) — kubectl/helm/flux policy enforcement
182
183
  7. **Everything else** — SAFE by elimination (auto-approved)
183
184