Skip to main content

Overview

Craft Agents implements defense-in-depth security with multiple layers:
  • AES-256-GCM encryption for credentials at rest
  • OAuth 2.0 + PKCE for secure authentication flows
  • MCP process isolation preventing cross-server data access
  • Environment variable filtering to prevent secret leakage
  • Permission modes limiting agent capabilities
All sensitive data (API keys, OAuth tokens) is stored encrypted. The app never stores credentials in plaintext.

Credential Encryption

Credentials are stored in an encrypted file using AES-256-GCM (Galois/Counter Mode) with authenticated encryption.

Storage Location

~/.craft-agent/credentials.enc
File permissions: 0600 (owner read/write only)

Encryption Scheme

// From secure-storage.ts
const ALGORITHM = 'aes-256-gcm';
const KEY_SIZE = 32;        // 256 bits
const IV_SIZE = 12;         // 96 bits (recommended for GCM)
const AUTH_TAG_SIZE = 16;   // 128 bits
const SALT_SIZE = 32;       // 256 bits
const PBKDF2_ITERATIONS = 100000;

File Format

[Header - 64 bytes]
├── Magic: "CRAFT01\0" (8 bytes)
├── Flags: uint32 LE (4 bytes) - reserved
├── Salt: 32 bytes (PBKDF2 salt)
└── Reserved: 20 bytes

[Encrypted Payload - variable]
├── IV: 12 bytes (random per write)
├── Auth Tag: 16 bytes (GCM authentication)
└── Ciphertext: variable (encrypted JSON)

Key Derivation

Encryption key is derived from hardware UUID using PBKDF2:
// From secure-storage.ts
function getStableMachineId(): string {
  if (process.platform === 'darwin') {
    // macOS: IOPlatformUUID (tied to logic board)
    return execSync('ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID');
  } else if (process.platform === 'win32') {
    // Windows: MachineGuid from registry
    return execSync('reg query HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid');
  } else {
    // Linux: dbus machine-id
    return readFileSync('/var/lib/dbus/machine-id', 'utf-8').trim();
  }
  
  // Fallback: username + homedir
  return `${userInfo().username}:${homedir()}`;
}

function deriveKey(salt: Buffer): Buffer {
  const machineId = getStableMachineId();
  return pbkdf2Sync(
    machineId,
    salt,
    PBKDF2_ITERATIONS,
    KEY_SIZE,
    'sha256'
  );
}
Hardware UUID is stable across reboots but unique per machine. Copying credentials.enc to another machine will fail decryption.

Encryption Process

// Simplified encryption flow
function encryptCredentials(data: CredentialStore): Buffer {
  // 1. Generate random salt and IV
  const salt = randomBytes(SALT_SIZE);
  const iv = randomBytes(IV_SIZE);
  
  // 2. Derive encryption key from machine ID
  const key = deriveKey(salt);
  
  // 3. Serialize data to JSON
  const plaintext = JSON.stringify(data);
  
  // 4. Encrypt with AES-256-GCM
  const cipher = createCipheriv('aes-256-gcm', key, iv);
  const ciphertext = Buffer.concat([
    cipher.update(plaintext, 'utf-8'),
    cipher.final()
  ]);
  const authTag = cipher.getAuthTag();
  
  // 5. Assemble file: Header + IV + AuthTag + Ciphertext
  return Buffer.concat([
    MAGIC_BYTES,
    Buffer.alloc(4),  // flags
    salt,
    Buffer.alloc(20), // reserved
    iv,
    authTag,
    ciphertext
  ]);
}

Decryption Process

function decryptCredentials(buffer: Buffer): CredentialStore {
  // 1. Validate magic bytes
  if (!buffer.subarray(0, 8).equals(MAGIC_BYTES)) {
    throw new Error('Invalid credentials file format');
  }
  
  // 2. Extract header components
  const salt = buffer.subarray(12, 44);
  const iv = buffer.subarray(64, 76);
  const authTag = buffer.subarray(76, 92);
  const ciphertext = buffer.subarray(92);
  
  // 3. Derive key from machine ID
  const key = deriveKey(salt);
  
  // 4. Decrypt and verify
  const decipher = createDecipheriv('aes-256-gcm', key, iv);
  decipher.setAuthTag(authTag);
  
  const plaintext = Buffer.concat([
    decipher.update(ciphertext),
    decipher.final()  // Throws if auth tag invalid
  ]);
  
  // 5. Parse JSON
  return JSON.parse(plaintext.toString('utf-8'));
}
GCM mode provides authenticated encryption — tampering with the file will cause decryption to fail with an auth tag error.

OAuth 2.0 Flows

Craft Agents uses OAuth 2.0 with PKCE (Proof Key for Code Exchange) for secure authentication without client secrets.

PKCE Flow

1

Generate code verifier

// From oauth.ts
const verifier = randomBytes(32).toString('base64url');
// Example: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
2

Compute code challenge

const challenge = createHash('sha256')
  .update(verifier)
  .digest('base64url');
// Example: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
3

Authorization request

GET /oauth/authorize
  ?client_id=craft-agent-xyz
  &redirect_uri=http://localhost:8914/oauth/callback
  &response_type=code
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &state=random-csrf-token
4

User authorizes in browser

OAuth provider shows consent screen, user approves.
5

Callback with authorization code

GET http://localhost:8914/oauth/callback
  ?code=AUTH_CODE_HERE
  &state=random-csrf-token
6

Token exchange

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTH_CODE_HERE
&redirect_uri=http://localhost:8914/oauth/callback
&client_id=craft-agent-xyz
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Server verifies: SHA256(code_verifier) == code_challenge
7

Store encrypted tokens

const tokens = await response.json();
await credentialManager.set(
  { type: 'source', provider: 'linear', account: 'oauth' },
  {
    type: 'oauth',
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token,
    expiresAt: Date.now() + tokens.expires_in * 1000
  }
);

Security Properties

No Client Secret

PKCE eliminates the need for a client secret, safe for public clients.

CSRF Protection

Random state parameter prevents cross-site request forgery.

Authorization Code Binding

code_verifier proves the client that started the flow is the one completing it.

Limited Scope

OAuth scopes restrict what the app can access (e.g., read-only vs full access).

Token Refresh

// From oauth.ts
async function refreshAccessToken(
  tokenEndpoint: string,
  clientId: string,
  refreshToken: string
): Promise<OAuthTokens> {
  const response = await fetch(tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: clientId
    })
  });
  
  if (!response.ok) {
    throw new Error('Token refresh failed');
  }
  
  const data = await response.json();
  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token || refreshToken,
    expiresAt: Date.now() + data.expires_in * 1000,
    tokenType: data.token_type
  };
}
Refresh tokens are long-lived (typically 90 days). Access tokens expire quickly (1-24 hours) and are refreshed automatically.

MCP Isolation

MCP (Model Context Protocol) servers run in separate processes with strict isolation:

Process Architecture

┌─────────────────────────────────────────┐
│ Main Process (Electron)                  │
│                                          │
│  ┌──────────────────┐                   │
│  │ McpClientPool    │                   │
│  │ - Manages all    │                   │
│  │   source conns   │                   │
│  └──────┬───────────┘                   │
│         │                                │
└─────────┼────────────────────────────────┘

          │ spawns subprocesses

          ├────────────────────┐
          │                    │
          ▼                    ▼
┌─────────────────┐  ┌─────────────────┐
│ MCP Server 1    │  │ MCP Server 2    │
│ (Linear)        │  │ (GitHub)        │
│                 │  │                 │
│ - Own memory    │  │ - Own memory    │
│ - Own env vars  │  │ - Own env vars  │
│ - No cross-talk │  │ - No cross-talk │
└─────────────────┘  └─────────────────┘

Isolation Guarantees

Memory Isolation
security
Each MCP server has its own memory space. One server cannot read data from another.
Credential Scoping
security
Credentials are passed only to the server that needs them, never shared between servers.
Tool Namespacing
security
Tools are prefixed with mcp__<slug>__ preventing name collisions and enabling source-specific permissions.
Crash Isolation
security
If one MCP server crashes, others continue running. The main app is unaffected.

Bridge MCP Server

For API sources (Linear, GitHub, etc.), the Bridge MCP Server acts as a secure proxy:
// Bridge reads credentials from cache file
const credentialPath = join(
  getSourcePath(workspaceRootPath, sourceSlug),
  '.credential-cache.json'
);

function readCredential(): StoredCredential | null {
  if (!existsSync(credentialPath)) return null;
  
  const data = readFileSync(credentialPath, 'utf-8');
  const cred = JSON.parse(data);
  
  // Check expiry
  if (cred.expiresAt && cred.expiresAt < Date.now()) {
    return null; // Expired
  }
  
  return cred;
}
Credential cache security:
  • File permissions: 0600 (owner read/write only)
  • Passive refresh: Bridge reads on each request, no active polling
  • Automatic expiry: Expired tokens cause auth errors, prompting re-auth
  • Per-source isolation: Each source has its own cache file
If an OAuth token expires mid-session, API requests will fail with auth errors. Users must re-authenticate to refresh the token.

Environment Variable Filtering

The agent filters environment variables to prevent accidental secret leakage:
// Sensitive patterns blocked from agent context
const BLOCKED_ENV_PATTERNS = [
  /^API_KEY$/i,
  /^SECRET$/i,
  /^PASSWORD$/i,
  /^TOKEN$/i,
  /^AWS_/i,
  /^OPENAI_/i,
  /^ANTHROPIC_/i
];

function filterEnvironment(env: Record<string, string>): Record<string, string> {
  const filtered: Record<string, string> = {};
  
  for (const [key, value] of Object.entries(env)) {
    if (BLOCKED_ENV_PATTERNS.some(pattern => pattern.test(key))) {
      continue; // Skip sensitive vars
    }
    filtered[key] = value;
  }
  
  return filtered;
}
Environment filtering applies to agent context only. MCP servers and bash commands still have access to the full environment.

Secure Credential Access

Credential Manager API

import { getCredentialManager } from '@craft-agent/shared/credentials';

const manager = getCredentialManager();

// Get credential (auto-decrypts)
const cred = await manager.get({
  type: 'llm',
  provider: 'claude',
  account: 'default'
});

// Set credential (auto-encrypts)
await manager.set(
  { type: 'source', provider: 'linear', account: 'oauth' },
  {
    type: 'oauth',
    accessToken: 'lin_...',
    refreshToken: 'rt_...',
    expiresAt: Date.now() + 3600000
  }
);

// Delete credential
await manager.delete({
  type: 'source',
  provider: 'linear',
  account: 'oauth'
});

Credential Types

type CredentialId = {
  type: 'llm' | 'source' | 'mcp';
  provider: string;
  account: string;
};

type StoredCredential = 
  | { type: 'api_key'; apiKey: string; }
  | { type: 'oauth'; accessToken: string; refreshToken?: string; expiresAt?: number; tokenType: string; }
  | { type: 'basic'; username: string; password: string; }
  | { type: 'bearer'; token: string; };

Security Best Practices

Never Commit Secrets

Keep .env files and credentials.enc out of version control.
.env
.env.*
credentials.enc
.credential-cache.json

Use Environment Variables

For local development, store secrets in .env files, never hardcode.
ANTHROPIC_API_KEY=sk-ant-...
LINEAR_API_KEY=lin_...

Review Execute Mode Usage

Only use Execute mode when you fully trust the agent’s plan. Default to Ask mode.

Audit Permissions Regularly

Periodically review permissions.json to ensure rules match your security requirements.

Rotate Credentials

Regularly rotate API keys and OAuth tokens, especially after team member departures.

Monitor MCP Server Logs

Check logs for unusual activity or failed auth attempts.
tail -f ~/.craft-agent/logs/mcp-*.log

Threat Model

Protected Against

Threat: Attacker gains read access to filesystemMitigation: AES-256-GCM encryption with hardware-derived key. Even with file access, attacker needs machine UUID to decrypt.
Threat: Attacker intercepts OAuth authorization codeMitigation: PKCE binds authorization code to the client that initiated the flow via code_verifier.
Threat: Attacker tricks user into authorizing malicious OAuth requestMitigation: Random state parameter validated on callback.
Threat: One compromised MCP server accesses data from anotherMitigation: Process isolation — each server runs in separate memory space with own credentials.
Threat: Agent accidentally includes secrets in prompts or logsMitigation: Environment filtering blocks sensitive variables from agent context.

Out of Scope

The following threats are not protected against and require defense at the OS level:
  • Memory dumping: Attacker with root/admin can read process memory
  • Keylogging: Attacker captures typed credentials before encryption
  • Physical access: Attacker with physical machine access can extract keys
  • Compromised OS: Malware with elevated privileges bypasses all protections

Compliance

Data Residency

  • Credentials: Stored locally at ~/.craft-agent/credentials.enc
  • Sessions: Stored locally at ~/.craft-agent/sessions/
  • No cloud sync: All sensitive data remains on your machine

GDPR Considerations

If you’re subject to GDPR:
  • Right to erasure: Delete workspace directories to remove all data
  • Data portability: Export workspace as JSON for portability
  • Minimal data collection: Only store what’s needed for functionality

Reporting Vulnerabilities

Found a security issue? Please report responsibly:
1

Email security team

Send details to [email protected] (do NOT use public GitHub issues)
2

Include details

  • Description of vulnerability
  • Steps to reproduce
  • Potential impact
  • Suggested fix (optional)
3

Wait for response

  • Acknowledgment within 48 hours
  • Initial assessment within 7 days
  • Resolution within 30 days for critical issues
We appreciate responsible disclosure and will acknowledge researchers (with permission) who report valid vulnerabilities.

Next Steps

Permission Modes

Learn about Explore, Ask, and Execute modes

Configuration

Customize safety rules with permissions.json

Build docs developers (and LLMs) love