drafted 1.14.6 → 1.14.7

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.
package/cli/drafted.mjs CHANGED
@@ -8,8 +8,8 @@
8
8
  */
9
9
 
10
10
  import { program } from 'commander';
11
- import { spawn, execSync } from 'child_process';
12
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
11
+ import { spawn, execSync, execFileSync } from 'child_process';
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync, chmodSync } from 'fs';
13
13
  import { join, dirname, basename, resolve } from 'path';
14
14
  import { homedir, tmpdir, platform } from 'os';
15
15
  import { fileURLToPath } from 'url';
@@ -38,9 +38,9 @@ const PACKAGE_VERSION = (() => {
38
38
  }
39
39
  })();
40
40
 
41
- // Ensure state directory exists
41
+ // Ensure state directory exists (0700 — holds the session token in auth.json)
42
42
  if (!existsSync(DEFAULT_STATE_DIR)) {
43
- mkdirSync(DEFAULT_STATE_DIR, { recursive: true });
43
+ mkdirSync(DEFAULT_STATE_DIR, { recursive: true, mode: 0o700 });
44
44
  }
45
45
 
46
46
  // Helper: Read projects
@@ -203,9 +203,13 @@ function readAuth() {
203
203
 
204
204
  function writeAuth(authData) {
205
205
  if (!existsSync(DEFAULT_STATE_DIR)) {
206
- mkdirSync(DEFAULT_STATE_DIR, { recursive: true });
206
+ mkdirSync(DEFAULT_STATE_DIR, { recursive: true, mode: 0o700 });
207
207
  }
208
- writeFileSync(DEFAULT_AUTH_FILE, JSON.stringify(authData, null, 2));
208
+ // SECURITY: auth.json holds the long-lived Drafted session token. Restrict it to the
209
+ // owner (0600) so no other local user on a shared host can read it. writeFileSync's mode
210
+ // only applies on create, so chmod after to also fix a pre-existing world-readable file.
211
+ writeFileSync(DEFAULT_AUTH_FILE, JSON.stringify(authData, null, 2), { mode: 0o600 });
212
+ try { chmodSync(DEFAULT_AUTH_FILE, 0o600); } catch { /* best effort */ }
209
213
  }
210
214
 
211
215
  function clearAuth() {
@@ -422,12 +426,15 @@ program
422
426
  console.log('');
423
427
  console.log(' If the browser doesn\'t open, visit the URL above manually.');
424
428
 
425
- // Open browser
429
+ // Open browser. SECURITY: verificationUrl comes from the server — pass it as an argv
430
+ // arg (execFileSync), never interpolated into a shell string, so a compromised/MITM'd
431
+ // server response can't inject `$(...)`/backticks that run on this host.
426
432
  try {
427
- const openCmd = process.platform === 'darwin' ? 'open'
428
- : process.platform === 'win32' ? 'start'
429
- : 'xdg-open';
430
- execSync(`${openCmd} "${verificationUrl}"`, { stdio: 'ignore' });
433
+ if (process.platform === 'win32') {
434
+ execFileSync('cmd', ['/c', 'start', '', verificationUrl], { stdio: 'ignore' });
435
+ } else {
436
+ execFileSync(process.platform === 'darwin' ? 'open' : 'xdg-open', [verificationUrl], { stdio: 'ignore' });
437
+ }
431
438
  } catch {
432
439
  // Browser open failed — user will use the URL manually
433
440
  }
@@ -1584,7 +1591,9 @@ async function syncOneSkill(ref, outDir, org) {
1584
1591
  mkdirSync(dirname(full), { recursive: true });
1585
1592
  writeFileSync(full, content);
1586
1593
  }
1587
- return { ref, slug, hash, status: 'ok' };
1594
+ // createdBy lets the caller decide whether to auto-run this skill's setup: shell authored
1595
+ // by someone else must not run unattended on sync (RCE via a teammate's/forked skill).
1596
+ return { ref, slug, hash, status: 'ok', createdBy: skill.createdBy ?? null };
1588
1597
  }
1589
1598
 
1590
1599
  const skillCmd = program.command('skill').description('Skill library operations');
@@ -1594,10 +1603,11 @@ skillCmd
1594
1603
  .option('--ref <ref>', 'skill ref slug[@hash] (repeatable)', (v, acc) => { acc.push(v); return acc; }, [])
1595
1604
  .requiredOption('--out <dir>', 'output directory for bundles')
1596
1605
  .option('--org <org>', 'resolve against this Drafted org (id or name); scopes per-request without switching the session')
1597
- .option('--no-setup', 'do not run each skill\'s setup after materializing (default: run setup so source-only skills are runnable)')
1606
+ .option('--no-setup', 'do not run each skill\'s setup after materializing (default: run setup only for skills you authored)')
1607
+ .option('--allow-untrusted-setup', 'also run setup for skills authored by someone else (SECURITY: runs their shell on this host — only for skills you trust)')
1598
1608
  .option('--format <fmt>', 'output format: json or text', 'text')
1599
1609
  .action(async (opts) => {
1600
- requireLogin();
1610
+ const auth = requireLogin();
1601
1611
  const refs = opts.ref || [];
1602
1612
  mkdirSync(opts.out, { recursive: true });
1603
1613
  const results = [];
@@ -1611,9 +1621,19 @@ skillCmd
1611
1621
  // failure is reported but the materialized source stays put (sync is still
1612
1622
  // `ok` — the bytes are there, just not built).
1613
1623
  if (r.status === 'ok' && opts.setup !== false) {
1614
- const s = runSkillSetup(join(opts.out, r.slug));
1615
- r.setup = s.skipped ? 'none' : (s.ok ? 'ok' : 'failed');
1616
- if (!s.ok && !s.skipped) r.setupError = s.failed ? `${s.failed}: ${s.error}` : s.error;
1624
+ // SECURITY: setup commands are arbitrary shell (execSync). Auto-run them ONLY for
1625
+ // skills the current user authored self-consent, like your own repo. Skills
1626
+ // authored by another org member, a fork, or an imported bundle must NOT run their
1627
+ // shell unattended (Causeway auto-syncs pinned skills), or a teammate's skill is
1628
+ // RCE on this host. `--allow-untrusted-setup` is the explicit opt-in.
1629
+ const selfAuthored = r.createdBy && auth?.userId && r.createdBy === auth.userId;
1630
+ if (selfAuthored || opts.allowUntrustedSetup) {
1631
+ const s = runSkillSetup(join(opts.out, r.slug));
1632
+ r.setup = s.skipped ? 'none' : (s.ok ? 'ok' : 'failed');
1633
+ if (!s.ok && !s.skipped) r.setupError = s.failed ? `${s.failed}: ${s.error}` : s.error;
1634
+ } else {
1635
+ r.setup = 'skipped-untrusted';
1636
+ }
1617
1637
  }
1618
1638
  results.push(r);
1619
1639
  if (r.status !== 'ok') allOk = false;
package/mcp/server.mjs CHANGED
@@ -1693,9 +1693,11 @@ if (!isRemote) tool('auth', 'Sign in to Drafted. On a local install the DESKTOP
1693
1693
  console.error(`\n[MCP] Sign in at: ${verificationUrl}\n${qrText ? qrText + '\n' : ''}[MCP] Waiting for approval...`);
1694
1694
 
1695
1695
  if (!reusingPending) {
1696
- const { exec } = await import('child_process');
1697
- const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
1698
- exec(`${cmd} ${JSON.stringify(verificationUrl)}`);
1696
+ // SECURITY: verificationUrl is server-supplied pass as an argv arg (execFile, no
1697
+ // shell) so a compromised/MITM'd server response can't inject shell into this host.
1698
+ const { execFile } = await import('child_process');
1699
+ if (process.platform === 'win32') execFile('cmd', ['/c', 'start', '', verificationUrl]);
1700
+ else execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [verificationUrl]);
1699
1701
  }
1700
1702
 
1701
1703
  const deadline = Date.now() + (expiresIn * 1000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.14.6",
3
+ "version": "1.14.7",
4
4
  "description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
5
5
  "type": "module",
6
6
  "files": [