crux-inject 0.1.0__tar.gz
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.
- crux_inject-0.1.0/LICENSE +21 -0
- crux_inject-0.1.0/PKG-INFO +207 -0
- crux_inject-0.1.0/README.md +185 -0
- crux_inject-0.1.0/crux/__init__.py +1 -0
- crux_inject-0.1.0/crux/aliases.py +43 -0
- crux_inject-0.1.0/crux/doctor.py +78 -0
- crux_inject-0.1.0/crux/injector.py +99 -0
- crux_inject-0.1.0/crux/keyring_manager.py +58 -0
- crux_inject-0.1.0/crux/main.py +384 -0
- crux_inject-0.1.0/crux/store.py +99 -0
- crux_inject-0.1.0/crux_inject.egg-info/PKG-INFO +207 -0
- crux_inject-0.1.0/crux_inject.egg-info/SOURCES.txt +20 -0
- crux_inject-0.1.0/crux_inject.egg-info/dependency_links.txt +1 -0
- crux_inject-0.1.0/crux_inject.egg-info/entry_points.txt +2 -0
- crux_inject-0.1.0/crux_inject.egg-info/requires.txt +9 -0
- crux_inject-0.1.0/crux_inject.egg-info/top_level.txt +1 -0
- crux_inject-0.1.0/pyproject.toml +43 -0
- crux_inject-0.1.0/setup.cfg +4 -0
- crux_inject-0.1.0/tests/test_aliases.py +59 -0
- crux_inject-0.1.0/tests/test_cli.py +130 -0
- crux_inject-0.1.0/tests/test_injector.py +96 -0
- crux_inject-0.1.0/tests/test_store.py +118 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Akash Bhujbal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crux-inject
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CRUX — Credential Runtime Unified eXecutor. Securely inject credentials into any AI tool.
|
|
5
|
+
Author-email: Akash Bhujbal <akashbhujbal100@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/akashbhujbalwebsite/crux
|
|
8
|
+
Project-URL: Issues, https://github.com/akashbhujbalwebsite/crux/issues
|
|
9
|
+
Keywords: credentials,security,ai,cli,secrets,injection
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: typer<1.0.0,>=0.12.0
|
|
14
|
+
Requires-Dist: rich>=13.0.0
|
|
15
|
+
Requires-Dist: cryptography<51.0.0,>=42.0.0
|
|
16
|
+
Requires-Dist: keyring<26.0.0,>=25.0.0
|
|
17
|
+
Requires-Dist: secretstorage<4.0.0,>=3.3.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# CRUX — Credential Runtime Unified eXecutor
|
|
24
|
+
|
|
25
|
+
> Store credentials encrypted on your machine. Auto-inject into any AI tool. Credentials never appear in chat transcripts.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## The Problem
|
|
30
|
+
|
|
31
|
+
Every day, developers paste passwords, API keys, and server credentials directly into AI chat tools to get help with tasks.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
❌ You type in chat: "my server password is MyP@ssw0rd123, can you SSH in?"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
That credential is now in the conversation — stored on Anthropic/OpenAI servers, visible in your transcript forever.
|
|
38
|
+
|
|
39
|
+
**Real numbers (2026):**
|
|
40
|
+
- 29 million secrets leaked on GitHub in 2025 — 34% YoY increase
|
|
41
|
+
- 1.27 million AI-specific keys exposed (OpenAI, Anthropic, Hugging Face)
|
|
42
|
+
- 3.2% secret-leak rate in AI-assisted commits vs 1.5% baseline
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## The Solution
|
|
47
|
+
|
|
48
|
+
CRUX stores your credentials encrypted locally and auto-injects them as environment variables into any AI tool.
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
✅ You say in chat: "SSH into $VM_USER@$VM_IP using $VM_PASS"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Claude/ChatGPT sees the variable names — never the actual values.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
**Linux / macOS (recommended):**
|
|
61
|
+
```bash
|
|
62
|
+
pipx install crux-inject
|
|
63
|
+
crux install
|
|
64
|
+
source ~/.bashrc
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
> **Ubuntu/Debian users:** `pip install` is blocked system-wide by default. Use `pipx` instead.
|
|
68
|
+
> Install pipx first if needed: `sudo apt install pipx && pipx ensurepath`
|
|
69
|
+
|
|
70
|
+
**Inside a virtual environment:**
|
|
71
|
+
```bash
|
|
72
|
+
pip install crux-inject
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> **Platform support:** Linux and macOS only. Windows users: use WSL2.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Quick Start
|
|
80
|
+
|
|
81
|
+
**1. Add credentials for a profile:**
|
|
82
|
+
```bash
|
|
83
|
+
crux add --profile my-server
|
|
84
|
+
|
|
85
|
+
# Adding credentials → profile: my-server
|
|
86
|
+
# Key name: VM_IP
|
|
87
|
+
# Value: 10.0.1.100
|
|
88
|
+
# Key name: VM_USER
|
|
89
|
+
# Value: admin
|
|
90
|
+
# Key name: VM_PASS
|
|
91
|
+
# Value: ••••••••••
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**2. Switch to that profile:**
|
|
95
|
+
```bash
|
|
96
|
+
crux use my-server
|
|
97
|
+
source ~/.bashrc
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**3. Your creds are now live as env vars:**
|
|
101
|
+
```bash
|
|
102
|
+
echo $VM_IP # 10.0.1.100
|
|
103
|
+
echo $VM_USER # admin
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**4. Launch any AI tool with creds injected:**
|
|
107
|
+
```bash
|
|
108
|
+
crux run claude
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Now tell Claude: *"SSH into $VM_USER@$VM_IP and check disk space"* — Claude runs the command, your password never enters the chat.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Commands
|
|
116
|
+
|
|
117
|
+
| Command | What it does |
|
|
118
|
+
|---------|-------------|
|
|
119
|
+
| `crux install` | One-time setup — adds shell hook to `~/.bashrc` |
|
|
120
|
+
| `crux add --profile <name>` | Add credentials interactively (hidden input) |
|
|
121
|
+
| `crux list` | Show all profiles and their key names |
|
|
122
|
+
| `crux use <profile>` | Switch active profile |
|
|
123
|
+
| `crux run <tool>` | Launch any tool with credentials injected |
|
|
124
|
+
| `crux remove <key> --profile <name>` | Delete a credential |
|
|
125
|
+
| `crux status` | Show current active profile and keys |
|
|
126
|
+
| `crux doctor` | Health check — CRUX setup + AI tools |
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## AI Tool Support
|
|
131
|
+
|
|
132
|
+
| You type | CRUX resolves to |
|
|
133
|
+
|----------|-----------------|
|
|
134
|
+
| `crux run claude` | `claude` |
|
|
135
|
+
| `crux run cursor` | `cursor-agent` |
|
|
136
|
+
| `crux run gemini` | `gemini-cli` |
|
|
137
|
+
| `crux run copilot` | `copilot` |
|
|
138
|
+
| `crux run <anything>` | any binary in PATH |
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Multi-Profile Workflow
|
|
143
|
+
|
|
144
|
+
Working across multiple environments? Create a profile per environment:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
crux add --profile server-prod
|
|
148
|
+
crux add --profile server-staging
|
|
149
|
+
crux add --profile aws-dev
|
|
150
|
+
|
|
151
|
+
crux list
|
|
152
|
+
# Profiles: server-prod (active), server-staging, aws-dev
|
|
153
|
+
|
|
154
|
+
crux use server-staging
|
|
155
|
+
source ~/.bashrc
|
|
156
|
+
crux run claude # Claude now gets staging creds
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## How It Works
|
|
162
|
+
|
|
163
|
+
CRUX adds a shell hook to `~/.bashrc`:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
eval "$(crux _hook)"
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
On every terminal open, this hook:
|
|
170
|
+
1. Exports credentials from the active profile as `export KEY=value`
|
|
171
|
+
2. Adds all credential key names to `HISTIGNORE` so they never appear in shell history
|
|
172
|
+
3. Runs `ulimit -c 0` to disable core dumps (prevents credentials leaking in crash files)
|
|
173
|
+
|
|
174
|
+
Credentials are stored encrypted at `~/.crux/store.enc` using **Fernet (AES-128)**. The encryption key is stored in your OS keyring (Linux Secret Service / macOS Keychain) with a file-based fallback at `~/.crux/.key` for headless servers.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Security
|
|
179
|
+
|
|
180
|
+
**What CRUX protects:**
|
|
181
|
+
- Credentials never typed into AI chat
|
|
182
|
+
- Credentials never in shell history
|
|
183
|
+
- Credentials never in core dump files
|
|
184
|
+
- Encrypted at rest (AES-128 Fernet)
|
|
185
|
+
- OS keyring integration for key storage
|
|
186
|
+
|
|
187
|
+
**Golden Rules:**
|
|
188
|
+
1. Never ask AI to *reveal* a credential — ask it to *use* it: `ssh into $VM_USER@$VM_IP`
|
|
189
|
+
2. Rotate credentials you shared in AI chat before using CRUX
|
|
190
|
+
3. Use `sshpass -e` not `sshpass -p` (avoids password visible in `ps aux`)
|
|
191
|
+
4. Use `sudo -E` to preserve env vars when sudoing
|
|
192
|
+
5. Never commit `~/.crux/` to git — excluded by default in `.gitignore`
|
|
193
|
+
|
|
194
|
+
See [SECURITY.md](SECURITY.md) for full details.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Requirements
|
|
199
|
+
|
|
200
|
+
- Python 3.10+
|
|
201
|
+
- Linux / macOS
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT © 2026 Akash Bhujbal
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# CRUX — Credential Runtime Unified eXecutor
|
|
2
|
+
|
|
3
|
+
> Store credentials encrypted on your machine. Auto-inject into any AI tool. Credentials never appear in chat transcripts.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## The Problem
|
|
8
|
+
|
|
9
|
+
Every day, developers paste passwords, API keys, and server credentials directly into AI chat tools to get help with tasks.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
❌ You type in chat: "my server password is MyP@ssw0rd123, can you SSH in?"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That credential is now in the conversation — stored on Anthropic/OpenAI servers, visible in your transcript forever.
|
|
16
|
+
|
|
17
|
+
**Real numbers (2026):**
|
|
18
|
+
- 29 million secrets leaked on GitHub in 2025 — 34% YoY increase
|
|
19
|
+
- 1.27 million AI-specific keys exposed (OpenAI, Anthropic, Hugging Face)
|
|
20
|
+
- 3.2% secret-leak rate in AI-assisted commits vs 1.5% baseline
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## The Solution
|
|
25
|
+
|
|
26
|
+
CRUX stores your credentials encrypted locally and auto-injects them as environment variables into any AI tool.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
✅ You say in chat: "SSH into $VM_USER@$VM_IP using $VM_PASS"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Claude/ChatGPT sees the variable names — never the actual values.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
**Linux / macOS (recommended):**
|
|
39
|
+
```bash
|
|
40
|
+
pipx install crux-inject
|
|
41
|
+
crux install
|
|
42
|
+
source ~/.bashrc
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
> **Ubuntu/Debian users:** `pip install` is blocked system-wide by default. Use `pipx` instead.
|
|
46
|
+
> Install pipx first if needed: `sudo apt install pipx && pipx ensurepath`
|
|
47
|
+
|
|
48
|
+
**Inside a virtual environment:**
|
|
49
|
+
```bash
|
|
50
|
+
pip install crux-inject
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> **Platform support:** Linux and macOS only. Windows users: use WSL2.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
**1. Add credentials for a profile:**
|
|
60
|
+
```bash
|
|
61
|
+
crux add --profile my-server
|
|
62
|
+
|
|
63
|
+
# Adding credentials → profile: my-server
|
|
64
|
+
# Key name: VM_IP
|
|
65
|
+
# Value: 10.0.1.100
|
|
66
|
+
# Key name: VM_USER
|
|
67
|
+
# Value: admin
|
|
68
|
+
# Key name: VM_PASS
|
|
69
|
+
# Value: ••••••••••
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**2. Switch to that profile:**
|
|
73
|
+
```bash
|
|
74
|
+
crux use my-server
|
|
75
|
+
source ~/.bashrc
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**3. Your creds are now live as env vars:**
|
|
79
|
+
```bash
|
|
80
|
+
echo $VM_IP # 10.0.1.100
|
|
81
|
+
echo $VM_USER # admin
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**4. Launch any AI tool with creds injected:**
|
|
85
|
+
```bash
|
|
86
|
+
crux run claude
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Now tell Claude: *"SSH into $VM_USER@$VM_IP and check disk space"* — Claude runs the command, your password never enters the chat.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Commands
|
|
94
|
+
|
|
95
|
+
| Command | What it does |
|
|
96
|
+
|---------|-------------|
|
|
97
|
+
| `crux install` | One-time setup — adds shell hook to `~/.bashrc` |
|
|
98
|
+
| `crux add --profile <name>` | Add credentials interactively (hidden input) |
|
|
99
|
+
| `crux list` | Show all profiles and their key names |
|
|
100
|
+
| `crux use <profile>` | Switch active profile |
|
|
101
|
+
| `crux run <tool>` | Launch any tool with credentials injected |
|
|
102
|
+
| `crux remove <key> --profile <name>` | Delete a credential |
|
|
103
|
+
| `crux status` | Show current active profile and keys |
|
|
104
|
+
| `crux doctor` | Health check — CRUX setup + AI tools |
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## AI Tool Support
|
|
109
|
+
|
|
110
|
+
| You type | CRUX resolves to |
|
|
111
|
+
|----------|-----------------|
|
|
112
|
+
| `crux run claude` | `claude` |
|
|
113
|
+
| `crux run cursor` | `cursor-agent` |
|
|
114
|
+
| `crux run gemini` | `gemini-cli` |
|
|
115
|
+
| `crux run copilot` | `copilot` |
|
|
116
|
+
| `crux run <anything>` | any binary in PATH |
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Multi-Profile Workflow
|
|
121
|
+
|
|
122
|
+
Working across multiple environments? Create a profile per environment:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
crux add --profile server-prod
|
|
126
|
+
crux add --profile server-staging
|
|
127
|
+
crux add --profile aws-dev
|
|
128
|
+
|
|
129
|
+
crux list
|
|
130
|
+
# Profiles: server-prod (active), server-staging, aws-dev
|
|
131
|
+
|
|
132
|
+
crux use server-staging
|
|
133
|
+
source ~/.bashrc
|
|
134
|
+
crux run claude # Claude now gets staging creds
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## How It Works
|
|
140
|
+
|
|
141
|
+
CRUX adds a shell hook to `~/.bashrc`:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
eval "$(crux _hook)"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
On every terminal open, this hook:
|
|
148
|
+
1. Exports credentials from the active profile as `export KEY=value`
|
|
149
|
+
2. Adds all credential key names to `HISTIGNORE` so they never appear in shell history
|
|
150
|
+
3. Runs `ulimit -c 0` to disable core dumps (prevents credentials leaking in crash files)
|
|
151
|
+
|
|
152
|
+
Credentials are stored encrypted at `~/.crux/store.enc` using **Fernet (AES-128)**. The encryption key is stored in your OS keyring (Linux Secret Service / macOS Keychain) with a file-based fallback at `~/.crux/.key` for headless servers.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Security
|
|
157
|
+
|
|
158
|
+
**What CRUX protects:**
|
|
159
|
+
- Credentials never typed into AI chat
|
|
160
|
+
- Credentials never in shell history
|
|
161
|
+
- Credentials never in core dump files
|
|
162
|
+
- Encrypted at rest (AES-128 Fernet)
|
|
163
|
+
- OS keyring integration for key storage
|
|
164
|
+
|
|
165
|
+
**Golden Rules:**
|
|
166
|
+
1. Never ask AI to *reveal* a credential — ask it to *use* it: `ssh into $VM_USER@$VM_IP`
|
|
167
|
+
2. Rotate credentials you shared in AI chat before using CRUX
|
|
168
|
+
3. Use `sshpass -e` not `sshpass -p` (avoids password visible in `ps aux`)
|
|
169
|
+
4. Use `sudo -E` to preserve env vars when sudoing
|
|
170
|
+
5. Never commit `~/.crux/` to git — excluded by default in `.gitignore`
|
|
171
|
+
|
|
172
|
+
See [SECURITY.md](SECURITY.md) for full details.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Requirements
|
|
177
|
+
|
|
178
|
+
- Python 3.10+
|
|
179
|
+
- Linux / macOS
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT © 2026 Akash Bhujbal
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
|
|
3
|
+
# Friendly name → list of real binary names to try (in order)
|
|
4
|
+
AI_ALIASES: dict[str, list[str]] = {
|
|
5
|
+
"claude": ["claude"],
|
|
6
|
+
"chatgpt": ["codex", "chatgpt"],
|
|
7
|
+
"codex": ["codex"],
|
|
8
|
+
"gemini": ["gemini", "antigravity"],
|
|
9
|
+
"antigravity": ["antigravity"],
|
|
10
|
+
"copilot": ["copilot", "gh-copilot"],
|
|
11
|
+
"cursor": ["cursor-agent", "agent", "cursor"],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
INSTALL_GUIDES: dict[str, str] = {
|
|
15
|
+
"claude": "npm install -g @anthropic-ai/claude-code",
|
|
16
|
+
"codex": "See https://learn.chatgpt.com/docs/codex/cli",
|
|
17
|
+
"gemini": "npm install -g @google/gemini-cli",
|
|
18
|
+
"antigravity": "npm install -g @google/antigravity-cli",
|
|
19
|
+
"copilot": "npm install -g @github/copilot",
|
|
20
|
+
"cursor-agent": "curl https://cursor.com/install -fsS | bash",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_command(name: str) -> str | None:
|
|
25
|
+
"""Resolve a friendly name or direct command to the actual binary in PATH."""
|
|
26
|
+
candidates = AI_ALIASES.get(name.lower(), [name])
|
|
27
|
+
for cmd in candidates:
|
|
28
|
+
if shutil.which(cmd):
|
|
29
|
+
return cmd
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_install_guide(name: str) -> str:
|
|
34
|
+
key = name.lower()
|
|
35
|
+
candidates = AI_ALIASES.get(key, [key])
|
|
36
|
+
for c in candidates:
|
|
37
|
+
if c in INSTALL_GUIDES:
|
|
38
|
+
return INSTALL_GUIDES[c]
|
|
39
|
+
return f"Install '{name}' and make sure it is in your PATH"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_all_known_tools() -> dict[str, list[str]]:
|
|
43
|
+
return AI_ALIASES
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from crux.keyring_manager import is_keyring_available
|
|
4
|
+
from crux.store import STORE_FILE, CRUX_DIR
|
|
5
|
+
from crux.aliases import INSTALL_GUIDES
|
|
6
|
+
|
|
7
|
+
SHELL_CONFIGS = [
|
|
8
|
+
Path.home() / ".bashrc",
|
|
9
|
+
Path.home() / ".zshrc",
|
|
10
|
+
Path.home() / ".config/fish/config.fish",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
HOOK_MARKER = "crux _hook"
|
|
14
|
+
|
|
15
|
+
AI_TOOLS: dict[str, dict] = {
|
|
16
|
+
"claude": {
|
|
17
|
+
"commands": ["claude"],
|
|
18
|
+
"install": INSTALL_GUIDES["claude"],
|
|
19
|
+
},
|
|
20
|
+
"codex (ChatGPT)": {
|
|
21
|
+
"commands": ["codex"],
|
|
22
|
+
"install": INSTALL_GUIDES["codex"],
|
|
23
|
+
},
|
|
24
|
+
"gemini": {
|
|
25
|
+
"commands": ["gemini", "antigravity"],
|
|
26
|
+
"install": INSTALL_GUIDES["gemini"],
|
|
27
|
+
},
|
|
28
|
+
"copilot": {
|
|
29
|
+
"commands": ["copilot"],
|
|
30
|
+
"install": INSTALL_GUIDES["copilot"],
|
|
31
|
+
},
|
|
32
|
+
"cursor": {
|
|
33
|
+
"commands": ["cursor-agent", "agent"],
|
|
34
|
+
"install": INSTALL_GUIDES["cursor-agent"],
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check_all() -> dict:
|
|
40
|
+
results: dict = {}
|
|
41
|
+
|
|
42
|
+
# Shell hook check
|
|
43
|
+
hook_found = False
|
|
44
|
+
for config in SHELL_CONFIGS:
|
|
45
|
+
if config.exists() and HOOK_MARKER in config.read_text():
|
|
46
|
+
hook_found = True
|
|
47
|
+
break
|
|
48
|
+
results["shell_hook"] = hook_found
|
|
49
|
+
|
|
50
|
+
# OS keyring
|
|
51
|
+
results["os_keyring"] = is_keyring_available()
|
|
52
|
+
|
|
53
|
+
# Store file
|
|
54
|
+
results["store"] = STORE_FILE.exists()
|
|
55
|
+
|
|
56
|
+
# Directory permissions
|
|
57
|
+
if CRUX_DIR.exists():
|
|
58
|
+
perms = oct(CRUX_DIR.stat().st_mode)[-3:]
|
|
59
|
+
results["permissions"] = perms == "700"
|
|
60
|
+
else:
|
|
61
|
+
results["permissions"] = False
|
|
62
|
+
|
|
63
|
+
# AI tool binaries
|
|
64
|
+
tool_results: dict = {}
|
|
65
|
+
for tool_name, info in AI_TOOLS.items():
|
|
66
|
+
found_cmd = None
|
|
67
|
+
for cmd in info["commands"]:
|
|
68
|
+
if shutil.which(cmd):
|
|
69
|
+
found_cmd = cmd
|
|
70
|
+
break
|
|
71
|
+
tool_results[tool_name] = {
|
|
72
|
+
"found": found_cmd is not None,
|
|
73
|
+
"command": found_cmd,
|
|
74
|
+
"install": info["install"],
|
|
75
|
+
}
|
|
76
|
+
results["tools"] = tool_results
|
|
77
|
+
|
|
78
|
+
return results
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
from crux.store import get_active_profile, get_profile_creds
|
|
5
|
+
from crux.aliases import resolve_command
|
|
6
|
+
|
|
7
|
+
# Key names must be safe shell identifiers — alphanumeric + underscore + hyphen only
|
|
8
|
+
_SAFE_KEY_RE = re.compile(r'^[A-Za-z0-9_-]+$')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sanitize_value(value: str) -> str:
|
|
12
|
+
"""
|
|
13
|
+
Strip null bytes and control characters that could break shell export statements.
|
|
14
|
+
Newlines are replaced with a space — credentials should never contain them.
|
|
15
|
+
"""
|
|
16
|
+
value = value.replace('\x00', '') # strip null bytes
|
|
17
|
+
value = re.sub(r'[\r\n]', ' ', value) # collapse newlines → space
|
|
18
|
+
value = re.sub(r'[\x01-\x1f\x7f]', '', value) # strip other control chars
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _shell_single_quote(value: str) -> str:
|
|
23
|
+
"""
|
|
24
|
+
Wrap value in single quotes with proper escaping.
|
|
25
|
+
Inside single quotes nothing is interpreted — the only character
|
|
26
|
+
that can break out is ' itself, which we replace with '\''.
|
|
27
|
+
"""
|
|
28
|
+
return "'" + value.replace("'", "'\\''") + "'"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def inject_and_run(command: str, args: tuple) -> tuple:
|
|
32
|
+
"""
|
|
33
|
+
Inject credentials from the active profile as env vars and run the command.
|
|
34
|
+
Returns (subprocess.CompletedProcess | None, error_string | None).
|
|
35
|
+
"""
|
|
36
|
+
profile = get_active_profile()
|
|
37
|
+
|
|
38
|
+
if not profile:
|
|
39
|
+
return None, "no_profile"
|
|
40
|
+
|
|
41
|
+
creds = get_profile_creds(profile)
|
|
42
|
+
|
|
43
|
+
if not creds:
|
|
44
|
+
return None, "empty_profile"
|
|
45
|
+
|
|
46
|
+
resolved = resolve_command(command)
|
|
47
|
+
if not resolved:
|
|
48
|
+
return None, f"not_found:{command}"
|
|
49
|
+
|
|
50
|
+
# Merge: system env + sanitized profile credentials
|
|
51
|
+
env = os.environ.copy()
|
|
52
|
+
env.update({k: _sanitize_value(v) for k, v in creds.items()})
|
|
53
|
+
|
|
54
|
+
full_cmd = [resolved] + list(args)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run(full_cmd, env=env)
|
|
58
|
+
return result, None
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
return None, f"not_found:{resolved}"
|
|
61
|
+
except KeyboardInterrupt:
|
|
62
|
+
return None, "interrupted"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_hook_exports() -> str:
|
|
66
|
+
"""
|
|
67
|
+
Output shell export statements for the active profile.
|
|
68
|
+
Called by the shell hook: eval "$(crux _hook)"
|
|
69
|
+
|
|
70
|
+
Also outputs:
|
|
71
|
+
- HISTIGNORE patterns for every credential key (fix 1)
|
|
72
|
+
- ulimit -c 0 to disable core dumps (fix 4)
|
|
73
|
+
"""
|
|
74
|
+
profile = get_active_profile()
|
|
75
|
+
if not profile:
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
creds = get_profile_creds(profile)
|
|
79
|
+
if not creds:
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
lines = []
|
|
83
|
+
|
|
84
|
+
# Export each credential — sanitized + single-quote escaped
|
|
85
|
+
for key, value in creds.items():
|
|
86
|
+
if not _SAFE_KEY_RE.match(key):
|
|
87
|
+
continue # skip any key that somehow has unsafe characters
|
|
88
|
+
safe_value = _shell_single_quote(_sanitize_value(value))
|
|
89
|
+
lines.append(f"export {key}={safe_value}")
|
|
90
|
+
|
|
91
|
+
# Fix 1: Dynamically extend HISTIGNORE with every credential key name
|
|
92
|
+
# so commands containing these var names are never saved to history
|
|
93
|
+
histignore_patterns = ":".join(f"*{key}*" for key in creds.keys())
|
|
94
|
+
lines.append(f'export HISTIGNORE="${{HISTIGNORE}}:{histignore_patterns}"')
|
|
95
|
+
|
|
96
|
+
# Fix 4: Disable core dumps to prevent credentials leaking into crash files
|
|
97
|
+
lines.append("ulimit -c 0")
|
|
98
|
+
|
|
99
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from cryptography.fernet import Fernet
|
|
4
|
+
|
|
5
|
+
CRUX_DIR = Path.home() / ".crux"
|
|
6
|
+
KEY_FILE = CRUX_DIR / ".key"
|
|
7
|
+
SERVICE_NAME = "crux-cli"
|
|
8
|
+
USERNAME = "master-key"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_key() -> bytes:
|
|
12
|
+
"""Get encryption key from OS keyring, fallback to file."""
|
|
13
|
+
try:
|
|
14
|
+
import keyring
|
|
15
|
+
key = keyring.get_password(SERVICE_NAME, USERNAME)
|
|
16
|
+
if key:
|
|
17
|
+
return key.encode()
|
|
18
|
+
except Exception as e:
|
|
19
|
+
import sys
|
|
20
|
+
print(f"[crux] OS keyring unavailable ({e}), using file-based fallback.", file=sys.stderr)
|
|
21
|
+
|
|
22
|
+
# Fallback: file-based key
|
|
23
|
+
if KEY_FILE.exists():
|
|
24
|
+
return KEY_FILE.read_bytes()
|
|
25
|
+
|
|
26
|
+
# Generate new key if nothing found
|
|
27
|
+
return _generate_key()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def set_key(key: bytes):
|
|
31
|
+
"""Store encryption key in OS keyring, fallback to file."""
|
|
32
|
+
try:
|
|
33
|
+
import keyring
|
|
34
|
+
keyring.set_password(SERVICE_NAME, USERNAME, key.decode())
|
|
35
|
+
return
|
|
36
|
+
except Exception as e:
|
|
37
|
+
import sys
|
|
38
|
+
print(f"[crux] OS keyring write failed ({e}), storing key in file.", file=sys.stderr)
|
|
39
|
+
|
|
40
|
+
# Fallback: file-based key
|
|
41
|
+
CRUX_DIR.mkdir(mode=0o700, exist_ok=True)
|
|
42
|
+
KEY_FILE.write_bytes(key)
|
|
43
|
+
KEY_FILE.chmod(0o600)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _generate_key() -> bytes:
|
|
47
|
+
key = Fernet.generate_key()
|
|
48
|
+
set_key(key)
|
|
49
|
+
return key
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_keyring_available() -> bool:
|
|
53
|
+
try:
|
|
54
|
+
import keyring
|
|
55
|
+
keyring.get_password("crux-test", "crux-test")
|
|
56
|
+
return True
|
|
57
|
+
except Exception:
|
|
58
|
+
return False
|