Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5b5ce28
fixes #59
helixclaw Feb 26, 2026
3652f94
fixes #60
helixclaw Feb 26, 2026
3dd96d2
fixes #61
helixclaw Feb 26, 2026
dce72b9
fixes #62
helixclaw Feb 26, 2026
5ed88ae
fixes #63
helixclaw Feb 26, 2026
fa38363
fixes #64
helixclaw Feb 26, 2026
f6531f0
fixes #65
helixclaw Feb 26, 2026
98f0e94
Fixes #49
helixclaw Feb 26, 2026
e348e1b
fix unlock command
NoahCardoza Feb 27, 2026
cacd430
create inject command
NoahCardoza Feb 27, 2026
ed748ff
move to tsup
NoahCardoza Feb 28, 2026
c9e2669
remove discord webhook in favor of bot message to prime reactions
NoahCardoza Feb 28, 2026
1415acf
Initial plan
Copilot Feb 28, 2026
1d28bf9
Initial plan
Copilot Feb 28, 2026
9426560
Update src/core/key-manager.ts
NoahCardoza Feb 28, 2026
357b74e
Remove toLowerCase from normalizeCommand to prevent case-based comman…
Copilot Feb 28, 2026
80333b0
Add GET /api/keys/public route so clients can fetch the server signin…
Copilot Feb 28, 2026
2453d55
remove old server entry file
NoahCardoza Feb 28, 2026
dee6771
add ability to config directory
NoahCardoza Feb 28, 2026
4a12096
implement zod for config parsing
NoahCardoza Feb 28, 2026
2a4a179
fix: correct template literal escaping in CI coverage badge step
NoahCardoza Feb 28, 2026
ac6241d
Merge pull request #90 from helixclaw/copilot/sub-pr-89
NoahCardoza Feb 28, 2026
46704a4
Merge pull request #91 from helixclaw/copilot/sub-pr-89-again
NoahCardoza Feb 28, 2026
f12a0e3
improve coverage testing
NoahCardoza Feb 28, 2026
ed79d05
add route to get signed grant jws token
NoahCardoza Feb 28, 2026
0d529c6
move Pull-in frequency into config file
NoahCardoza Feb 28, 2026
790f0d1
fix client mode and add comprehensive integration tests
NoahCardoza Feb 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .claudeignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Dependencies - pnpm creates hundreds of nested node_modules
node_modules/

# Build outputs
dist/
build/
coverage/
.nyc_output/

# Temporary files
*.tmp
*.temp
.cache/
tmp/

# Logs
logs/
*.log

# IDE
.vscode/
.idea/
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ jobs:
cache: npm
- run: npm ci
- run: npm run lint
- run: npx tsc --noEmit
- run: npm run test:coverage
- run: npm run compile
- run: npm run test

- name: Update coverage badge
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
Expand All @@ -40,9 +40,9 @@ jobs:
}
const color = coverage >= 80 ? 'brightgreen' : coverage >= 60 ? 'yellow' : 'red';
let readme = fs.readFileSync('README.md', 'utf8');
readme = readme.replace(/coverage-[0-9]+%25-[a-z]+/g, \\\`coverage-\\\${coverage}%25-\\\${color}\\\`);
readme = readme.replace(/coverage-[0-9]+%25-[a-z]+/g, \`coverage-\${coverage}%25-\${color}\`);
fs.writeFileSync('README.md', readme);
console.log(\\\`Coverage: \\\${coverage}% (\\\${color})\\\`);
console.log(\`Coverage: \${coverage}% (\${color})\`);
} catch (err) {
console.error('Failed to update coverage badge:', err.message);
process.exit(1);
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ dist/
.env.*
*.tsbuildinfo
coverage/
.claude/settings.local.json
74 changes: 53 additions & 21 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,62 @@
# 2keychains - Project Conventions
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

- **Build:** `npm run build` (compiles TypeScript to `dist/`)
- **Dev:** `npm run dev` (runs CLI directly via tsx)
- **Test:** `npm test` (runs Vitest)
- **Build:** `npm run build` (uses tsup, outputs to `dist/`)
- **Dev:** `npm run dev` (tsup watch mode)
- **Test:** `npm test` (Vitest with coverage, 95% threshold)
- **Test (watch):** `npm run test:watch`
- **Lint:** `npm run lint` (ESLint + Prettier check)
- **Lint fix:** `npm run lint:fix` (auto-fix ESLint + Prettier)
- **Test (no coverage):** `npm run test:no-coverage`
- **Single test:** `npx vitest run src/__tests__/filename.test.ts`
- **Lint:** `npm run lint` (ESLint + Prettier, auto-fix)
- **Lint (check only):** `npm run lint:no-fix`
- **Type check:** `npm run compile` (tsc --noEmit)

## Architecture

2keychains is a local secret broker for AI agents. It replaces direct secret access with a controlled intermediary featuring approval workflows, placeholder injection, and output redaction.

### Service Layer (`src/core/service.ts`)

The `Service` interface is the central abstraction. Two implementations:

- **LocalService** — Standalone mode. Owns the encrypted store, unlock session, grant manager, and workflow engine. Handles the full lifecycle: unlock → request → approve → inject.
- **RemoteService** — Client mode. Proxies requests to a running 2kc server via HTTP.

`resolveService(config)` returns the appropriate implementation based on `config.mode`.

### Request-Grant Flow

1. CLI creates an `AccessRequest` via `Service.requests.create()`
2. `WorkflowEngine.processRequest()` checks if approval is needed (based on secret tags and `requireApproval` config)
3. If approval required, sends to `NotificationChannel` (Discord) and waits for response
4. On approval, `GrantManager.createGrant()` creates a signed JWT grant
5. `SecretInjector.inject()` resolves `2k://` placeholders and runs the command with secrets in env

### Key Components

- **EncryptedSecretStore** — AES-GCM encrypted secret storage with Argon2 KDF
- **UnlockSession** — In-memory DEK holder with TTL, idle timeout, and max-grants limits
- **SessionLock** — Persists session state to disk for CLI session continuity
- **GrantManager** — Issues and validates Ed25519-signed JWS grants
- **SecretInjector** — Resolves placeholders, spawns subprocess, redacts secrets from output
- **WorkflowEngine** — Orchestrates approval flow between store, channel, and config

### Server (`src/server/`)

Fastify server with bearer token auth. Routes delegate to the `Service` interface.

## Directory Structure
### Channels (`src/channels/`)

```
src/
cli/ # CLI entry point and command definitions
core/ # Core business logic
channels/ # Channel implementations (Discord, etc.)
__tests__/ # Test files
dist/ # Build output (gitignored)
```
`NotificationChannel` interface for approval workflows. Discord implementation sends embeds and polls for emoji reactions.

## Coding Conventions

- **Module system:** ESM (`"type": "module"` in package.json)
- **TypeScript:** Strict mode, ES2022 target, Node16 module resolution
- **Formatting:** Prettier (no semicolons, single quotes, trailing commas)
- **Testing:** Vitest with globals enabled; test files use `*.test.ts` pattern in `src/`
- **CLI framework:** Commander; command name is `2kc`
- **Node.js:** Requires >=20.0.0
- **ESM only** `"type": "module"`, use `.js` extensions in imports
- **Strict TypeScript** ES2022 target, Node16 module resolution
- **Prettier** — No semicolons, single quotes, trailing commas
- **Tests** Vitest with globals; `*.test.ts` files in `src/__tests__/`
- **CLI** Commander framework; binary name is `2kc`
- **Node.js** — Requires >=20.0.0
51 changes: 38 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,30 @@ Options:
- `--cmd` (required) — Command to run with secrets injected
- `--duration <seconds>` — Grant validity (default: 300, range: 30–3600)

### Inject command

For workflows with multiple secrets, use the `inject` command which scans environment variables for `2k://` placeholders:

```bash
# Set env vars with secret placeholders
export DB_PASS="2k://db-password"
export API_KEY="2k://api-key-prod"

# Inject all found placeholders
2kc inject --reason "Deploy" --task "DEPLOY-123" --cmd "./deploy.sh"

# Only inject specific vars
2kc inject --vars "DB_PASS,API_KEY" --reason "test" --task "T-1" --cmd "./run.sh"
```

Options:

- `--reason` (required) — Justification for access
- `--task` (required) — Task/ticket reference
- `--cmd` (required) — Command to run with secrets injected
- `--vars <varList>` — Comma-separated list of env var names to check (default: scan all)
- `--duration <seconds>` — Grant validity (default: 300)

### 6. View configuration

```bash
Expand Down Expand Up @@ -159,19 +183,20 @@ Config file: `~/.2kc/config.json`

### Config Fields

| Field | Type | Default | Description |
| ------------------------ | ---------------------------- | ----------------------- | --------------------------------------------------------------------------- |
| `mode` | `"standalone"` \| `"client"` | `"standalone"` | Operating mode. Standalone runs locally; client connects to a remote server |
| `server.host` | string | `"127.0.0.1"` | Server bind address |
| `server.port` | number | `2274` | Server port |
| `server.authToken` | string | — | Bearer token for client-server auth |
| `store.path` | string | `"~/.2kc/secrets.json"` | Path to the secrets JSON file |
| `discord.webhookUrl` | string | — | Discord webhook URL for approval messages |
| `discord.botToken` | string | — | Discord bot token for reading reactions |
| `discord.channelId` | string | — | Discord channel ID for approval polling |
| `requireApproval` | object | `{}` | Tag → boolean map. Tags set to `true` require human approval |
| `defaultRequireApproval` | boolean | `false` | Default approval requirement for untagged secrets |
| `approvalTimeoutMs` | number | `300000` | How long to wait for approval (ms) |
| Field | Type | Default | Description |
| ------------------------ | ---------------------------- | --------------------------- | --------------------------------------------------------------------------- |
| `mode` | `"standalone"` \| `"client"` | `"standalone"` | Operating mode. Standalone runs locally; client connects to a remote server |
| `server.host` | string | `"127.0.0.1"` | Server bind address |
| `server.port` | number | `2274` | Server port |
| `server.authToken` | string | — | Bearer token for client-server auth |
| `server.pollIntervalMs` | number | `3000` | Polling interval for grant status (ms) |
| `store.path` | string | `"~/.2kc/secrets.enc.json"` | Path to the secrets JSON file |
| `discord.webhookUrl` | string | — | Discord webhook URL for approval messages |
| `discord.botToken` | string | — | Discord bot token for reading reactions |
| `discord.channelId` | string | — | Discord channel ID for approval polling |
| `requireApproval` | object | `{}` | Tag → boolean map. Tags set to `true` require human approval |
| `defaultRequireApproval` | boolean | `false` | Default approval requirement for untagged secrets |
| `approvalTimeoutMs` | number | `300000` | How long to wait for approval (ms) |

## Server Mode

Expand Down
6 changes: 2 additions & 4 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ pre-commit:
commands:
typecheck:
glob: '*.{ts,tsx}'
run: npm run build
run: npm run compile
lint:
glob: '*.{ts,tsx,js,json,md}'
run: npm run lint
coverage:
glob: '*.{ts,tsx}'
run: npm run test:coverage
stage_fixed: true
test:
glob: '*.{ts,tsx}'
run: npm run test
Loading