@adrive/cli 0.2.1 → 0.3.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.
@@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs';
2
2
  import kleur from 'kleur';
3
3
  import { CliApiClient } from '../lib/api-client.js';
4
4
  import { requireLogin } from '../lib/config.js';
5
- import { decryptContent, isEncrypted } from '../lib/crypto.js';
5
+ import { decryptContentAsync, isEncrypted } from '../lib/crypto.js';
6
6
  export async function cmdPull(spec, opts = {}) {
7
7
  const cfg = await requireLogin();
8
8
  const client = new CliApiClient({
@@ -19,7 +19,7 @@ export async function cmdPull(spec, opts = {}) {
19
19
  const remotePath = spec.slice(slash + 1);
20
20
  const file = await client.readFileByPath(workspaceId, remotePath);
21
21
  const content = isEncrypted(file.content)
22
- ? decryptContent(cfg.api_key, file.content)
22
+ ? await decryptContentAsync(cfg.api_key, file.content)
23
23
  : file.content;
24
24
  if (opts.out) {
25
25
  await fs.writeFile(opts.out, content, 'utf8');
@@ -3,7 +3,7 @@ import { basename } from 'node:path';
3
3
  import kleur from 'kleur';
4
4
  import { CliApiClient } from '../lib/api-client.js';
5
5
  import { requireLogin } from '../lib/config.js';
6
- import { encryptContent } from '../lib/crypto.js';
6
+ import { encryptContentAsync } from '../lib/crypto.js';
7
7
  export async function cmdPush(localPath, opts = {}) {
8
8
  const cfg = await requireLogin();
9
9
  const client = new CliApiClient({
@@ -23,7 +23,7 @@ export async function cmdPush(localPath, opts = {}) {
23
23
  encrypt = false;
24
24
  else
25
25
  encrypt = cfg.preferences?.private_workspace ?? false;
26
- const content = encrypt ? encryptContent(cfg.api_key, raw) : raw;
26
+ const content = encrypt ? await encryptContentAsync(cfg.api_key, raw) : raw;
27
27
  let workspaceId = opts.workspace ?? cfg.default_workspace;
28
28
  if (!workspaceId) {
29
29
  const list = await client.listWorkspaces();
@@ -0,0 +1,210 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, basename } from 'node:path';
4
+ import kleur from 'kleur';
5
+ import { CliApiClient } from '../lib/api-client.js';
6
+ import { requireLogin, configDir } from '../lib/config.js';
7
+ import { encryptContentAsync } from '../lib/crypto.js';
8
+ import { redact, summarizeHits } from '../lib/redactor.js';
9
+ function manifestPath() {
10
+ return join(configDir(), 'transcripts.manifest.json');
11
+ }
12
+ async function readManifest() {
13
+ try {
14
+ const raw = await fs.readFile(manifestPath(), 'utf8');
15
+ return JSON.parse(raw);
16
+ }
17
+ catch {
18
+ return { ingested: {} };
19
+ }
20
+ }
21
+ async function writeManifest(m) {
22
+ await fs.mkdir(configDir(), { recursive: true, mode: 0o700 });
23
+ await fs.writeFile(manifestPath(), JSON.stringify(m, null, 2), { encoding: 'utf8', mode: 0o600 });
24
+ }
25
+ async function findClaudeTranscripts() {
26
+ const projectsDir = join(homedir(), '.claude', 'projects');
27
+ let projects;
28
+ try {
29
+ projects = await fs.readdir(projectsDir);
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ const out = [];
35
+ for (const p of projects) {
36
+ const pdir = join(projectsDir, p);
37
+ let files;
38
+ try {
39
+ files = await fs.readdir(pdir);
40
+ }
41
+ catch {
42
+ continue;
43
+ }
44
+ for (const f of files) {
45
+ if (!f.endsWith('.jsonl'))
46
+ continue;
47
+ const full = join(pdir, f);
48
+ let stat;
49
+ try {
50
+ stat = await fs.stat(full);
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ out.push({
56
+ sessionId: f.replace(/\.jsonl$/, ''),
57
+ path: full,
58
+ mtime: stat.mtime,
59
+ projectDir: p,
60
+ });
61
+ }
62
+ }
63
+ return out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
64
+ }
65
+ function extractTurn(line) {
66
+ let obj;
67
+ try {
68
+ obj = JSON.parse(line);
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ const type = obj.type ?? '';
74
+ const ts = obj.timestamp ?? undefined;
75
+ if (type === 'user' || type === 'assistant') {
76
+ const msg = obj.message;
77
+ if (!msg)
78
+ return null;
79
+ const role = msg.role ?? type;
80
+ const content = msg.content;
81
+ let text = '';
82
+ if (typeof content === 'string') {
83
+ text = content;
84
+ }
85
+ else if (Array.isArray(content)) {
86
+ for (const part of content) {
87
+ if (typeof part === 'string')
88
+ text += part;
89
+ else if (part && typeof part === 'object' && 'text' in part) {
90
+ text += String(part.text);
91
+ }
92
+ }
93
+ }
94
+ if (!text.trim())
95
+ return null;
96
+ return { role, text, ts };
97
+ }
98
+ return null;
99
+ }
100
+ function formatTranscript(turns) {
101
+ const lines = [];
102
+ for (const t of turns) {
103
+ lines.push(`## ${t.role}${t.ts ? ` _(${t.ts})_` : ''}`);
104
+ lines.push('');
105
+ lines.push(t.text);
106
+ lines.push('');
107
+ }
108
+ return lines.join('\n');
109
+ }
110
+ export async function cmdTranscriptsIngest(opts = {}) {
111
+ const cfg = await requireLogin();
112
+ if (!cfg.preferences?.capture_transcripts && !opts.dryRun) {
113
+ process.stderr.write(`${kleur.yellow('[warn]')} Remember conversations is off in your preferences.\n` +
114
+ ` Enable with \`adrive privacy --capture-transcripts on\`,\n` +
115
+ ` or run a one-off dry-run with \`adrive transcripts ingest --dry-run\`.\n`);
116
+ process.exit(2);
117
+ }
118
+ const platform = opts.platform ?? 'claude-code';
119
+ if (platform !== 'claude-code') {
120
+ throw new Error(`Transcript ingestion for ${platform} is not implemented yet.`);
121
+ }
122
+ const all = await findClaudeTranscripts();
123
+ let entries = all;
124
+ if (opts.since) {
125
+ const since = new Date(opts.since);
126
+ if (Number.isNaN(since.getTime()))
127
+ throw new Error('Invalid --since date.');
128
+ entries = entries.filter((e) => e.mtime > since);
129
+ }
130
+ if (typeof opts.limit === 'number')
131
+ entries = entries.slice(0, opts.limit);
132
+ if (entries.length === 0) {
133
+ process.stdout.write('No transcripts found.\n');
134
+ return;
135
+ }
136
+ const manifest = await readManifest();
137
+ const client = new CliApiClient({
138
+ baseUrl: cfg.api_url,
139
+ apiKey: cfg.api_key,
140
+ agentPlatform: cfg.agent_platform ?? 'cli',
141
+ agentRole: cfg.agent_role,
142
+ });
143
+ let workspaceId = opts.workspace ?? cfg.default_workspace;
144
+ if (!workspaceId && !opts.dryRun) {
145
+ const list = await client.listWorkspaces();
146
+ if (list.length === 1)
147
+ workspaceId = list[0].id;
148
+ else if (list.length === 0)
149
+ throw new Error('No workspaces. Create one first.');
150
+ else
151
+ throw new Error('Multiple workspaces — specify --workspace <id>.');
152
+ }
153
+ const encrypt = !!cfg.preferences?.private_workspace;
154
+ process.stdout.write(`${kleur.bold(`Found ${entries.length} ${platform} transcripts.`)}\n`);
155
+ let ingested = 0;
156
+ let skipped = 0;
157
+ for (const e of entries) {
158
+ if (!opts.reingest && manifest.ingested[e.sessionId]) {
159
+ skipped++;
160
+ continue;
161
+ }
162
+ let raw;
163
+ try {
164
+ raw = await fs.readFile(e.path, 'utf8');
165
+ }
166
+ catch {
167
+ continue;
168
+ }
169
+ const turns = [];
170
+ for (const line of raw.split(/\r?\n/)) {
171
+ if (!line.trim())
172
+ continue;
173
+ const turn = extractTurn(line);
174
+ if (turn)
175
+ turns.push(turn);
176
+ }
177
+ if (turns.length === 0) {
178
+ skipped++;
179
+ continue;
180
+ }
181
+ const md = `# Transcript — ${platform} / ${e.sessionId}\n\n` +
182
+ `Project: ${e.projectDir}\nLast modified: ${e.mtime.toISOString()}\nTurns: ${turns.length}\n\n` +
183
+ formatTranscript(turns);
184
+ const { redacted, hits } = redact(md);
185
+ const remotePath = `transcripts/${platform}/${e.sessionId}.md`;
186
+ const tag = summarizeHits(hits);
187
+ if (opts.dryRun) {
188
+ process.stdout.write(`${kleur.dim('[dry]')} ${remotePath} ${kleur.dim(`(${turns.length} turns, redactor: ${tag})`)}\n`);
189
+ ingested++;
190
+ continue;
191
+ }
192
+ try {
193
+ const body = encrypt ? await encryptContentAsync(cfg.api_key, redacted) : redacted;
194
+ await client.writeFile(workspaceId, remotePath, body, `transcript ingest (${tag})`);
195
+ manifest.ingested[e.sessionId] = new Date().toISOString();
196
+ ingested++;
197
+ process.stdout.write(`${kleur.green('[ok]')} ${remotePath}` +
198
+ (encrypt ? kleur.cyan(' (encrypted)') : '') +
199
+ (hits.length ? kleur.yellow(` (redacted: ${tag})`) : '') +
200
+ '\n');
201
+ }
202
+ catch (err) {
203
+ const msg = err instanceof Error ? err.message : String(err);
204
+ process.stderr.write(`${kleur.red('[err]')} ${basename(e.path)}: ${msg}\n`);
205
+ }
206
+ }
207
+ if (!opts.dryRun)
208
+ await writeManifest(manifest);
209
+ process.stdout.write(`\nIngested ${ingested}, skipped ${skipped}.\n`);
210
+ }
@@ -0,0 +1,110 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { resolve, relative, sep, posix } from 'node:path';
3
+ import { watch as chokidarWatch } from 'chokidar';
4
+ import kleur from 'kleur';
5
+ import { CliApiClient } from '../lib/api-client.js';
6
+ import { requireLogin } from '../lib/config.js';
7
+ import { encryptContentAsync } from '../lib/crypto.js';
8
+ import { redact, summarizeHits } from '../lib/redactor.js';
9
+ import { loadRules, shouldIgnore } from '../lib/ignore.js';
10
+ function toPosix(p) {
11
+ return p.split(sep).join(posix.sep);
12
+ }
13
+ export async function cmdWatch(rawRoot, opts = {}) {
14
+ const root = resolve(rawRoot ?? process.cwd());
15
+ const cfg = await requireLogin();
16
+ if (!cfg.preferences?.capture_artifacts && !opts.dryRun) {
17
+ process.stderr.write(`${kleur.yellow('[warn]')} Capture artifacts is off in your preferences.\n` +
18
+ ` Either enable it with \`adrive privacy --capture-artifacts on\`\n` +
19
+ ` or run a one-off dry-run with \`adrive watch --dry-run\`.\n`);
20
+ process.exit(2);
21
+ }
22
+ const dryRun = !!opts.dryRun;
23
+ const encrypt = !opts.noEncrypt && !!cfg.preferences?.private_workspace;
24
+ const debounceMs = opts.debounce ?? 750;
25
+ const rules = await loadRules(root);
26
+ const client = new CliApiClient({
27
+ baseUrl: cfg.api_url,
28
+ apiKey: cfg.api_key,
29
+ agentPlatform: cfg.agent_platform ?? 'cli',
30
+ agentRole: cfg.agent_role,
31
+ });
32
+ let workspaceId = opts.workspace ?? cfg.default_workspace;
33
+ if (!workspaceId && !dryRun) {
34
+ const list = await client.listWorkspaces();
35
+ if (list.length === 1)
36
+ workspaceId = list[0].id;
37
+ else if (list.length === 0)
38
+ throw new Error('No workspaces. Create one first.');
39
+ else
40
+ throw new Error(`Multiple workspaces — specify --workspace <id>.`);
41
+ }
42
+ process.stdout.write(`${kleur.bold('AgentDrive watch')}\n`);
43
+ process.stdout.write(` root: ${root}\n`);
44
+ process.stdout.write(` workspace: ${workspaceId ?? kleur.dim('(dry-run, none)')}\n`);
45
+ process.stdout.write(` mode: ${dryRun ? kleur.yellow('dry-run — nothing uploads') : kleur.green('live')}\n`);
46
+ process.stdout.write(` encryption: ${encrypt ? kleur.cyan('end-to-end on your device') : 'off'}\n`);
47
+ process.stdout.write(` redactor: on (secrets are scrubbed before anything leaves)\n\n`);
48
+ const queue = new Map();
49
+ let timer;
50
+ async function flush() {
51
+ const pending = [...queue.values()];
52
+ queue.clear();
53
+ for (const item of pending) {
54
+ try {
55
+ const raw = await fs.readFile(item.abs, 'utf8');
56
+ const { redacted, hits } = redact(raw);
57
+ const remotePath = toPosix(item.rel);
58
+ const tag = summarizeHits(hits);
59
+ if (dryRun) {
60
+ process.stdout.write(`${kleur.dim('[dry]')} ${item.reason.padEnd(6)} ${remotePath} ${kleur.dim(`(${raw.length}B, redactor: ${tag})`)}\n`);
61
+ continue;
62
+ }
63
+ const body = encrypt ? await encryptContentAsync(cfg.api_key, redacted) : redacted;
64
+ const message = `adrive watch: ${item.reason}${hits.length ? ` (redacted ${tag})` : ''}`;
65
+ const result = await client.writeFile(workspaceId, remotePath, body, message);
66
+ process.stdout.write(`${kleur.green('[ok]')} ${item.reason.padEnd(6)} ${remotePath} v${result.version}` +
67
+ (encrypt ? kleur.cyan(' (encrypted)') : '') +
68
+ (hits.length ? kleur.yellow(` (redacted: ${tag})`) : '') +
69
+ '\n');
70
+ }
71
+ catch (err) {
72
+ const msg = err instanceof Error ? err.message : String(err);
73
+ process.stderr.write(`${kleur.red('[err]')} ${item.rel}: ${msg}\n`);
74
+ }
75
+ }
76
+ }
77
+ function enqueue(absPath, reason) {
78
+ if (shouldIgnore(root, absPath, rules))
79
+ return;
80
+ const rel = relative(root, absPath);
81
+ queue.set(absPath, { abs: absPath, rel, reason });
82
+ if (timer)
83
+ clearTimeout(timer);
84
+ timer = setTimeout(() => {
85
+ void flush();
86
+ }, debounceMs);
87
+ }
88
+ const watcher = chokidarWatch(root, {
89
+ ignoreInitial: false,
90
+ persistent: true,
91
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
92
+ ignored: (p) => shouldIgnore(root, resolve(p), rules),
93
+ });
94
+ watcher.on('add', (p) => enqueue(resolve(p), 'add'));
95
+ watcher.on('change', (p) => enqueue(resolve(p), 'change'));
96
+ watcher.on('error', (err) => {
97
+ process.stderr.write(`${kleur.red('[err]')} watcher: ${String(err)}\n`);
98
+ });
99
+ const shutdown = async () => {
100
+ if (timer)
101
+ clearTimeout(timer);
102
+ await flush();
103
+ await watcher.close();
104
+ process.exit(0);
105
+ };
106
+ process.on('SIGINT', () => void shutdown());
107
+ process.on('SIGTERM', () => void shutdown());
108
+ // Resolve only when the user kills the process.
109
+ await new Promise(() => { });
110
+ }
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ import { cmdRunLog } from './commands/run-log.js';
15
15
  import { cmdBilling } from './commands/billing.js';
16
16
  import { cmdPlan } from './commands/plan.js';
17
17
  import { cmdPrivacy } from './commands/privacy.js';
18
+ import { cmdWatch } from './commands/watch.js';
19
+ import { cmdTranscriptsIngest } from './commands/transcripts.js';
18
20
  // Read from the bundled package.json so we never drift again.
19
21
  import { readFileSync } from 'node:fs';
20
22
  import { fileURLToPath } from 'node:url';
@@ -127,6 +129,37 @@ program
127
129
  .command('plan')
128
130
  .description('Show current plan, usage, and quotas')
129
131
  .action(wrap(() => cmdPlan()));
132
+ program
133
+ .command('watch [path]')
134
+ .description('Capture files your agents write under the given path (default: cwd)')
135
+ .option('-w, --workspace <id>', 'Workspace id (default: only workspace)')
136
+ .option('--dry-run', 'Print what would be uploaded; never write')
137
+ .option('--no-encrypt', 'Skip end-to-end encryption even when private workspace is on')
138
+ .option('--debounce <ms>', 'Coalesce changes within this window', (v) => Number(v))
139
+ .action(wrap((path, opts) => cmdWatch(path, {
140
+ workspace: opts.workspace,
141
+ dryRun: opts.dryRun,
142
+ noEncrypt: opts.encrypt === false,
143
+ debounce: opts.debounce,
144
+ })));
145
+ const transcripts = program.command('transcripts').description('Ingest chat transcripts from local agent hosts');
146
+ transcripts
147
+ .command('ingest')
148
+ .description('Read recent transcripts, redact secrets, upload to AgentDrive')
149
+ .option('--platform <p>', 'Only ingest one platform (default: claude-code)', 'claude-code')
150
+ .option('-w, --workspace <id>', 'Target workspace id')
151
+ .option('--since <date>', 'Ignore transcripts older than this ISO date')
152
+ .option('--limit <n>', 'Cap how many to ingest', (v) => Number(v))
153
+ .option('--dry-run', 'Print what would be uploaded; never write')
154
+ .option('--reingest', 'Re-upload sessions already in the manifest')
155
+ .action(wrap((opts) => cmdTranscriptsIngest({
156
+ platform: opts.platform,
157
+ workspace: opts.workspace,
158
+ since: opts.since,
159
+ limit: opts.limit,
160
+ dryRun: opts.dryRun,
161
+ reingest: opts.reingest,
162
+ })));
130
163
  program
131
164
  .command('privacy')
132
165
  .description('Show or change your privacy + memory preferences')
@@ -1,26 +1,26 @@
1
- import { createCipheriv, createDecipheriv, randomBytes, scryptSync, } from 'node:crypto';
2
- // AgentDrive private-workspace encryption.
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
2
+ import { loadDek } from './keychain.js';
3
+ // AgentDrive private-workspace encryption (v1).
3
4
  //
4
- // Goal: a file uploaded with privacy on is opaque to the server. Even if our
5
- // storage is breached, the bytes are unreadable without the local key.
5
+ // Sync API (`encryptContent` / `decryptContent`) keeps the legacy v0
6
+ // derivation: scrypt(apiKey, fixedSalt). Used by tests + any caller that
7
+ // can't await. The async API (`encryptContentAsync` / `decryptContentAsync`)
8
+ // uses the keychain-stored DEK from lib/keychain.ts — that's the path the
9
+ // real `adrive push` / `adrive pull` take.
6
10
  //
7
- // v0 derives the workspace data key from the API key with a scrypt KDF and a
8
- // fixed salt. That gives a single client-side key per principal — enough to
9
- // protect content from the server, not enough to protect content from a
10
- // compromised API key. v1 moves the master key into the OS keychain and uses
11
- // per-workspace data keys wrapped by it.
12
- const SALT = Buffer.from('agentdrive.privacy.v0', 'utf8');
13
- const KEY_LENGTH = 32; // AES-256
14
- const IV_LENGTH = 12; // GCM standard
15
- const TAG_LENGTH = 16; // GCM standard
11
+ // Why two paths: a published v0.2.x ciphertext on disk should still decrypt
12
+ // after the upgrade. The async path is opt-in and used for new writes.
13
+ const LEGACY_SALT = Buffer.from('agentdrive.privacy.v0', 'utf8');
14
+ const KEY_LENGTH = 32;
15
+ const IV_LENGTH = 12;
16
+ const TAG_LENGTH = 16;
16
17
  const SCRYPT_COST = 1 << 14;
17
18
  const SCRYPT_MAXMEM = 64 * 1024 * 1024;
18
19
  const ENCRYPTED_PREFIX = 'agentdrive-encrypted:v0:';
19
20
  export function deriveWorkspaceKey(apiKey) {
20
- return scryptSync(apiKey, SALT, KEY_LENGTH, { N: SCRYPT_COST, maxmem: SCRYPT_MAXMEM });
21
+ return scryptSync(apiKey, LEGACY_SALT, KEY_LENGTH, { N: SCRYPT_COST, maxmem: SCRYPT_MAXMEM });
21
22
  }
22
- export function encryptContent(apiKey, plaintext) {
23
- const key = deriveWorkspaceKey(apiKey);
23
+ function encryptWithKey(key, plaintext) {
24
24
  const iv = randomBytes(IV_LENGTH);
25
25
  const cipher = createCipheriv('aes-256-gcm', key, iv);
26
26
  const buf = typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : plaintext;
@@ -33,10 +33,7 @@ export function encryptContent(apiKey, plaintext) {
33
33
  };
34
34
  return ENCRYPTED_PREFIX + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
35
35
  }
36
- export function isEncrypted(content) {
37
- return typeof content === 'string' && content.startsWith(ENCRYPTED_PREFIX);
38
- }
39
- export function decryptContent(apiKey, encoded) {
36
+ function decryptWithKey(key, encoded) {
40
37
  if (!isEncrypted(encoded)) {
41
38
  throw new Error('Content is not AgentDrive-encrypted.');
42
39
  }
@@ -47,9 +44,39 @@ export function decryptContent(apiKey, encoded) {
47
44
  if (iv.length !== IV_LENGTH || tag.length !== TAG_LENGTH) {
48
45
  throw new Error('Malformed encrypted payload.');
49
46
  }
50
- const key = deriveWorkspaceKey(apiKey);
51
47
  const decipher = createDecipheriv('aes-256-gcm', key, iv);
52
48
  decipher.setAuthTag(tag);
53
49
  const ciphertext = Buffer.from(payload.ciphertext, 'base64');
54
50
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
55
51
  }
52
+ export function encryptContent(apiKey, plaintext) {
53
+ return encryptWithKey(deriveWorkspaceKey(apiKey), plaintext);
54
+ }
55
+ export function decryptContent(apiKey, encoded) {
56
+ if (!isEncrypted(encoded)) {
57
+ throw new Error('Content is not AgentDrive-encrypted.');
58
+ }
59
+ return decryptWithKey(deriveWorkspaceKey(apiKey), encoded);
60
+ }
61
+ export async function encryptContentAsync(apiKey, plaintext) {
62
+ const dek = await loadDek(apiKey);
63
+ return encryptWithKey(dek, plaintext);
64
+ }
65
+ export async function decryptContentAsync(apiKey, encoded) {
66
+ const dek = await loadDek(apiKey);
67
+ try {
68
+ return decryptWithKey(dek, encoded);
69
+ }
70
+ catch (errKeychain) {
71
+ // Fallback for ciphertext written by v0 builds (pre-keychain).
72
+ try {
73
+ return decryptWithKey(deriveWorkspaceKey(apiKey), encoded);
74
+ }
75
+ catch {
76
+ throw errKeychain;
77
+ }
78
+ }
79
+ }
80
+ export function isEncrypted(content) {
81
+ return typeof content === 'string' && content.startsWith(ENCRYPTED_PREFIX);
82
+ }
@@ -0,0 +1,137 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join, relative, sep, posix } from 'node:path';
3
+ // .adriveignore parser. Subset of gitignore semantics: blank lines and lines
4
+ // starting with '#' are skipped; '!' negates a previous pattern; '/' anchors
5
+ // to the project root; '**' matches across directories; '*' matches within a
6
+ // segment.
7
+ //
8
+ // In addition we always layer a default-deny set on top so first-time users
9
+ // don't accidentally upload .env / .ssh / .aws / node_modules even before
10
+ // they edit the file.
11
+ export const DEFAULT_DENY = [
12
+ '.git/',
13
+ 'node_modules/',
14
+ '.next/',
15
+ '.open-next/',
16
+ '.turbo/',
17
+ 'dist/',
18
+ 'build/',
19
+ 'coverage/',
20
+ '.cache/',
21
+ '.DS_Store',
22
+ '.env',
23
+ '.env.*',
24
+ '*.pem',
25
+ '*.key',
26
+ '*.p12',
27
+ '*.pfx',
28
+ '*.crt',
29
+ '.ssh/',
30
+ '.aws/',
31
+ '.gcp/',
32
+ '.kube/',
33
+ '.docker/config.json',
34
+ '.npmrc',
35
+ '.pypirc',
36
+ '.netrc',
37
+ 'id_rsa',
38
+ 'id_ed25519',
39
+ 'credentials.json',
40
+ 'serviceAccount.json',
41
+ '*.sqlite',
42
+ '*.sqlite3',
43
+ '*.db',
44
+ '__pycache__/',
45
+ ];
46
+ function patternToRegex(raw) {
47
+ let pat = raw.trim();
48
+ const negate = pat.startsWith('!');
49
+ if (negate)
50
+ pat = pat.slice(1);
51
+ // Trailing slash means directory-only; treat it as match-anywhere-inside.
52
+ const dirOnly = pat.endsWith('/');
53
+ if (dirOnly)
54
+ pat = pat.slice(0, -1);
55
+ // Anchor to root if leading '/'.
56
+ const anchored = pat.startsWith('/');
57
+ if (anchored)
58
+ pat = pat.slice(1);
59
+ let re = '';
60
+ let i = 0;
61
+ while (i < pat.length) {
62
+ const c = pat[i];
63
+ if (c === '*') {
64
+ // '**' matches anything including slashes; single '*' matches within
65
+ // a path segment only.
66
+ if (pat[i + 1] === '*') {
67
+ re += '.*';
68
+ i += 2;
69
+ if (pat[i] === '/')
70
+ i++;
71
+ }
72
+ else {
73
+ re += '[^/]*';
74
+ i++;
75
+ }
76
+ }
77
+ else if (c === '?') {
78
+ re += '[^/]';
79
+ i++;
80
+ }
81
+ else if ('.+^$()[]{}|\\'.includes(c)) {
82
+ re += '\\' + c;
83
+ i++;
84
+ }
85
+ else {
86
+ re += c;
87
+ i++;
88
+ }
89
+ }
90
+ // dirOnly: match the dir itself or anything under it.
91
+ if (dirOnly)
92
+ re += '(?:/.*)?';
93
+ // anchored = at root only; unanchored = at any depth.
94
+ const final = anchored ? `^${re}$` : `^(?:.*/)?${re}$`;
95
+ return { re: new RegExp(final), negate };
96
+ }
97
+ export function compileRules(lines) {
98
+ const rules = [];
99
+ for (const raw of lines) {
100
+ const line = raw.replace(/^\s+|\s+$/g, '');
101
+ if (!line || line.startsWith('#'))
102
+ continue;
103
+ const { re, negate } = patternToRegex(line);
104
+ rules.push({ pattern: line, negate, re });
105
+ }
106
+ return rules;
107
+ }
108
+ export async function loadRules(root) {
109
+ const candidates = [join(root, '.adriveignore'), join(root, '.gitignore')];
110
+ const lines = [...DEFAULT_DENY];
111
+ for (const c of candidates) {
112
+ try {
113
+ const raw = await fs.readFile(c, 'utf8');
114
+ lines.push(...raw.split(/\r?\n/));
115
+ }
116
+ catch {
117
+ // ignore missing
118
+ }
119
+ }
120
+ return compileRules(lines);
121
+ }
122
+ function normalizeRelative(root, path) {
123
+ const rel = relative(root, path);
124
+ return rel.split(sep).join(posix.sep);
125
+ }
126
+ export function shouldIgnore(root, path, rules) {
127
+ const rel = normalizeRelative(root, path);
128
+ if (!rel || rel.startsWith('..'))
129
+ return true;
130
+ let ignored = false;
131
+ for (const r of rules) {
132
+ if (r.re.test(rel)) {
133
+ ignored = !r.negate;
134
+ }
135
+ }
136
+ return ignored;
137
+ }
@@ -0,0 +1,93 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
3
+ import { hostname, userInfo } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { configDir } from './config.js';
6
+ // AgentDrive lightweight keychain (v1, software-only).
7
+ //
8
+ // Goal: a random 256-bit data-encryption key (DEK), persisted across runs,
9
+ // wrapped with a key-encrypting key (KEK) derived from
10
+ // scrypt(host || user || api_key, salt).
11
+ //
12
+ // This is stronger than "derive DEK directly from API key" because:
13
+ // - Rotating the API key doesn't change the DEK, so old ciphertext is
14
+ // still decryptable after rotation (with the same KEK derivation).
15
+ // - The DEK is random, not derivable from the API key alone.
16
+ // - host+user binding means a stolen API key cannot decrypt files
17
+ // without also having access to the user's home + machine identity.
18
+ //
19
+ // v2 will replace the KEK derivation with the OS keychain
20
+ // (Keychain.app / libsecret / DPAPI). The interface stays the same.
21
+ const KEY_LENGTH = 32;
22
+ const IV_LENGTH = 12;
23
+ const SALT_LENGTH = 16;
24
+ const TAG_LENGTH = 16;
25
+ const SCRYPT_N = 1 << 14;
26
+ const SCRYPT_MAXMEM = 64 * 1024 * 1024;
27
+ const FILE_VERSION = 1;
28
+ export function keychainPath(env = process.env) {
29
+ return join(configDir(env), 'keychain.bin');
30
+ }
31
+ function deriveKek(apiKey, salt) {
32
+ // Bind to the machine + user. A stolen API key alone won't unwrap the DEK.
33
+ const ident = `${hostname()}|${userInfo().username}|${apiKey}`;
34
+ return scryptSync(ident, salt, KEY_LENGTH, { N: SCRYPT_N, maxmem: SCRYPT_MAXMEM });
35
+ }
36
+ async function readBlob(path) {
37
+ try {
38
+ const raw = await fs.readFile(path, 'utf8');
39
+ const parsed = JSON.parse(raw);
40
+ if (parsed.v !== FILE_VERSION)
41
+ return null;
42
+ return parsed;
43
+ }
44
+ catch (err) {
45
+ const e = err;
46
+ if (e.code === 'ENOENT')
47
+ return null;
48
+ return null;
49
+ }
50
+ }
51
+ async function writeBlob(path, blob) {
52
+ await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
53
+ await fs.writeFile(path, JSON.stringify(blob), { encoding: 'utf8', mode: 0o600 });
54
+ }
55
+ export async function loadDek(apiKey, path = keychainPath()) {
56
+ const existing = await readBlob(path);
57
+ if (existing) {
58
+ const salt = Buffer.from(existing.salt, 'base64');
59
+ const iv = Buffer.from(existing.iv, 'base64');
60
+ const tag = Buffer.from(existing.tag, 'base64');
61
+ const ct = Buffer.from(existing.ct, 'base64');
62
+ const kek = deriveKek(apiKey, salt);
63
+ const dec = createDecipheriv('aes-256-gcm', kek, iv);
64
+ dec.setAuthTag(tag);
65
+ return Buffer.concat([dec.update(ct), dec.final()]);
66
+ }
67
+ // First use: create a fresh DEK and persist wrapped.
68
+ const dek = randomBytes(KEY_LENGTH);
69
+ const salt = randomBytes(SALT_LENGTH);
70
+ const iv = randomBytes(IV_LENGTH);
71
+ const kek = deriveKek(apiKey, salt);
72
+ const enc = createCipheriv('aes-256-gcm', kek, iv);
73
+ const ct = Buffer.concat([enc.update(dek), enc.final()]);
74
+ const tag = enc.getAuthTag();
75
+ if (tag.length !== TAG_LENGTH)
76
+ throw new Error('Unexpected GCM tag length');
77
+ await writeBlob(path, {
78
+ v: FILE_VERSION,
79
+ salt: salt.toString('base64'),
80
+ iv: iv.toString('base64'),
81
+ tag: tag.toString('base64'),
82
+ ct: ct.toString('base64'),
83
+ });
84
+ return dek;
85
+ }
86
+ export async function purgeKeychain(path = keychainPath()) {
87
+ try {
88
+ await fs.unlink(path);
89
+ }
90
+ catch {
91
+ // ignore
92
+ }
93
+ }
@@ -0,0 +1,69 @@
1
+ // Secret redactor. Runs on every payload before capture features upload
2
+ // content. Patterns are intentionally conservative — false positives are
3
+ // tagged and replaced with a safe marker; false negatives are the risk we
4
+ // optimize against.
5
+ //
6
+ // IMPORTANT: redaction is best-effort, not a security boundary. It is one
7
+ // layer in a defense-in-depth stack: .adriveignore for path-level deny,
8
+ // redactor for content-level scrub, optional client-side encryption for
9
+ // confidentiality.
10
+ // Order matters: more specific patterns first so they win over the generic
11
+ // high-entropy fallback.
12
+ const PATTERNS = [
13
+ // Provider-specific keys
14
+ { kind: 'aws-access-key-id', re: /\bAKIA[0-9A-Z]{16}\b/g, marker: () => '[redacted:aws-access-key-id]' },
15
+ { kind: 'aws-secret', re: /\b(?:aws_secret_access_key|AWS_SECRET_ACCESS_KEY)[\s:=]+['"]?[A-Za-z0-9/+=]{40}['"]?/g, marker: () => '[redacted:aws-secret]' },
16
+ { kind: 'gcp-service-account', re: /"private_key":\s*"-----BEGIN PRIVATE KEY-----[\s\S]*?-----END PRIVATE KEY-----[^"]*"/g, marker: () => '"private_key":"[redacted:gcp-service-account]"' },
17
+ { kind: 'stripe-secret', re: /\bsk_(?:live|test)_[A-Za-z0-9]{16,99}\b/g, marker: () => '[redacted:stripe-secret]' },
18
+ { kind: 'stripe-restricted', re: /\brk_(?:live|test)_[A-Za-z0-9]{16,99}\b/g, marker: () => '[redacted:stripe-restricted]' },
19
+ { kind: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g, marker: () => '[redacted:github-token]' },
20
+ { kind: 'github-fine-grained', re: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g, marker: () => '[redacted:github-fine-grained]' },
21
+ { kind: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g, marker: () => '[redacted:anthropic-key]' },
22
+ { kind: 'openai-key', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g, marker: () => '[redacted:openai-key]' },
23
+ { kind: 'agentdrive-key', re: /\bagentdrive_live_[A-Za-z0-9]{16,}/g, marker: () => '[redacted:agentdrive-key]' },
24
+ { kind: 'slack-bot-token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, marker: () => '[redacted:slack-token]' },
25
+ { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, marker: () => '[redacted:jwt]' },
26
+ // Private keys (PEM-style blocks). Match the whole block.
27
+ { kind: 'private-key-pem', re: /-----BEGIN (?:RSA |OPENSSH |EC |DSA |ENCRYPTED |PRIVATE)?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |OPENSSH |EC |DSA |ENCRYPTED |PRIVATE)?PRIVATE KEY-----/g, marker: () => '[redacted:private-key]' },
28
+ // Generic high-entropy assignment forms: VAR=secret-looking-string
29
+ { kind: 'env-secret', re: /\b(?:[A-Z][A-Z0-9_]*_(?:KEY|SECRET|TOKEN|PASS(?:WORD)?|PWD|CREDENTIAL))\s*[:=]\s*['"]?([A-Za-z0-9+/_=-]{16,})['"]?/g, marker: (m) => m.replace(/(.*?[:=]\s*['"]?)([A-Za-z0-9+/_=-]{16,})(['"]?)/, '$1[redacted:env-secret]$3') },
30
+ ];
31
+ function offsetToLineCol(text, offset) {
32
+ let line = 1;
33
+ let lastNewline = -1;
34
+ for (let i = 0; i < offset; i++) {
35
+ if (text.charCodeAt(i) === 10) {
36
+ line++;
37
+ lastNewline = i;
38
+ }
39
+ }
40
+ return { line, column: offset - lastNewline };
41
+ }
42
+ export function redact(input) {
43
+ let out = input;
44
+ const hits = [];
45
+ for (const p of PATTERNS) {
46
+ out = out.replace(p.re, (match, ..._args) => {
47
+ // Locate this match in the *original* input for line/col reporting.
48
+ // Using the original gives stable coordinates the user can verify.
49
+ const idx = input.indexOf(match);
50
+ if (idx >= 0) {
51
+ const { line, column } = offsetToLineCol(input, idx);
52
+ hits.push({ kind: p.kind, line, column, length: match.length });
53
+ }
54
+ return p.marker(match);
55
+ });
56
+ }
57
+ return { redacted: out, hits };
58
+ }
59
+ export function summarizeHits(hits) {
60
+ if (hits.length === 0)
61
+ return 'no secrets detected';
62
+ const counts = {};
63
+ for (const h of hits)
64
+ counts[h.kind] = (counts[h.kind] ?? 0) + 1;
65
+ return Object.entries(counts)
66
+ .sort(([, a], [, b]) => b - a)
67
+ .map(([k, n]) => `${k}×${n}`)
68
+ .join(', ');
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrive/cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "AgentDrive CLI — the persistent workspace your agents share across Claude Code, Codex, Cursor, and LangGraph.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -44,6 +44,7 @@
44
44
  "test": "vitest run"
45
45
  },
46
46
  "dependencies": {
47
+ "chokidar": "^5.0.0",
47
48
  "commander": "^12.1.0",
48
49
  "kleur": "^4.1.5",
49
50
  "open": "^10.1.0",