drafted 1.14.5 → 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 +37 -17
- package/mcp/server.mjs +132 -25
- package/mcp/test-org-guards.mjs +33 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
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
|
@@ -169,6 +169,22 @@ function scrubLocalPathMentions(description) {
|
|
|
169
169
|
// inside the factory so each HTTP request gets its own isolated server.
|
|
170
170
|
// Stdio mode uses the `mcpServer` singleton (built once at module load).
|
|
171
171
|
|
|
172
|
+
// ── Org-ambiguity policy (the one decision core) ─────────────────
|
|
173
|
+
// The org guard inside the factory plumbs session/HTTP state into this
|
|
174
|
+
// side-effect-free predicate, which IS the policy (DRAFT-36 "one rule"). Top-level
|
|
175
|
+
// + exported so the truth table can be asserted in isolation (mcp/test-org-guards.mjs).
|
|
176
|
+
// It governs BOTH creates and forks (a fork is a create): a write proceeds when its
|
|
177
|
+
// org is a real root — an explicit org=, a bound/active project, or a single-org
|
|
178
|
+
// user's only org — and refuses to GUESS only when the user is multi-org with
|
|
179
|
+
// nothing bound. A bound project is honored deliberately (Reading A): the active
|
|
180
|
+
// project is the one legitimately-kept addressing root, NOT the deleted session
|
|
181
|
+
// cursor. Awareness of a surprising destination (e.g. a fork) comes from the
|
|
182
|
+
// response receipt naming the org (orgEcho), not from hard-blocking the flow.
|
|
183
|
+
export function projectlessMutationNeedsOrg({ explicitOrg, boundOrgId, activeProjectId, isRemote, orgCount }) {
|
|
184
|
+
if (explicitOrg || boundOrgId || activeProjectId || isRemote) return false;
|
|
185
|
+
return (orgCount || 0) > 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
172
188
|
export function createMcpServer(transport) {
|
|
173
189
|
// Remote transports (hosted HTTP MCP for claude.ai / ChatGPT) run on the
|
|
174
190
|
// server, not the user's machine, so local-filesystem params like `file_path`
|
|
@@ -1327,18 +1343,12 @@ async function requireBoundOrgForProjectlessMutation(explicitOrg) {
|
|
|
1327
1343
|
if (ctx?.id) sess.boundOrgId = ctx.id;
|
|
1328
1344
|
return;
|
|
1329
1345
|
}
|
|
1330
|
-
|
|
1331
|
-
if (
|
|
1332
|
-
|
|
1333
|
-
const d = await api('GET', '/api/orgs');
|
|
1334
|
-
count = (d.orgs || d || []).length;
|
|
1335
|
-
sess.cachedOrgCount = count;
|
|
1336
|
-
} catch { return; } // can't determine membership — don't block a legit write
|
|
1337
|
-
}
|
|
1338
|
-
if (count > 1) {
|
|
1346
|
+
const orgs = await getOrgList();
|
|
1347
|
+
if (!orgs.length) return; // can't determine membership — don't block a legit write
|
|
1348
|
+
if (projectlessMutationNeedsOrg({ orgCount: orgs.length })) {
|
|
1339
1349
|
throw new Error(
|
|
1340
1350
|
`Refusing to guess the org: no project is open and no explicit org was ` +
|
|
1341
|
-
`given, but you belong to ${
|
|
1351
|
+
`given, but you belong to ${orgs.length} orgs — a project-less wiki/skill write ` +
|
|
1342
1352
|
`would land in whichever org the session inherited. Pass org=... on this ` +
|
|
1343
1353
|
`call (org name or id), or open the relevant project first ` +
|
|
1344
1354
|
`(project(action="open") — the org derives from the project).`
|
|
@@ -1346,6 +1356,46 @@ async function requireBoundOrgForProjectlessMutation(explicitOrg) {
|
|
|
1346
1356
|
}
|
|
1347
1357
|
}
|
|
1348
1358
|
|
|
1359
|
+
// The user's org list, cached per-session (30s TTL) — shared by the ambiguity
|
|
1360
|
+
// guards and the response org-echo so a mutation costs at most one /api/orgs hit.
|
|
1361
|
+
// Returns [] (not throw) on failure: callers treat "unknown membership" as
|
|
1362
|
+
// "don't block", matching the prior guard behavior.
|
|
1363
|
+
async function getOrgList() {
|
|
1364
|
+
const sess = getSessionState();
|
|
1365
|
+
if (sess.cachedOrgs && Date.now() - (sess.cachedOrgsTime || 0) < 30000) return sess.cachedOrgs;
|
|
1366
|
+
try {
|
|
1367
|
+
const d = await api('GET', '/api/orgs');
|
|
1368
|
+
sess.cachedOrgs = (d.orgs || d || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
|
|
1369
|
+
sess.cachedOrgsTime = Date.now();
|
|
1370
|
+
} catch { sess.cachedOrgs = sess.cachedOrgs || []; }
|
|
1371
|
+
return sess.cachedOrgs;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
// Build the agent-facing "receipt" for a mutation: the org it actually wrote to
|
|
1376
|
+
// (+ a URL handle when the result carries one), so an implicit org resolution is
|
|
1377
|
+
// visible in the response instead of opaque. Best-effort: returns {} on any
|
|
1378
|
+
// failure so it never breaks a successful write. Prefers the org id the server
|
|
1379
|
+
// put on the created/edited row, then the explicit org arg, then the session org.
|
|
1380
|
+
// This is the agent-facing half of the receipt spine (DRAFT-36 P1); the
|
|
1381
|
+
// human-facing card/notice (P4) consumes the same shape server-side.
|
|
1382
|
+
async function orgEcho(result, explicitOrg) {
|
|
1383
|
+
try {
|
|
1384
|
+
const orgs = await getOrgList();
|
|
1385
|
+
let orgId = result?.orgId || result?.org_id || null;
|
|
1386
|
+
if (!orgId && explicitOrg) {
|
|
1387
|
+
const hit = orgs.find(o => o.id === explicitOrg || (o.name || '').toLowerCase() === String(explicitOrg).toLowerCase());
|
|
1388
|
+
orgId = hit?.id || explicitOrg;
|
|
1389
|
+
}
|
|
1390
|
+
if (!orgId) { const ctx = await getCurrentOrgContext(); orgId = ctx?.id || null; }
|
|
1391
|
+
if (!orgId) return {};
|
|
1392
|
+
const receipt = { org: { id: orgId, name: orgs.find(o => o.id === orgId)?.name || null } };
|
|
1393
|
+
const url = result?.url || result?.frameUrl || null;
|
|
1394
|
+
if (url) receipt.url = url;
|
|
1395
|
+
return { receipt };
|
|
1396
|
+
} catch { return {}; }
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1349
1399
|
async function getOrgSkills(orgId) {
|
|
1350
1400
|
if (!orgId) return [];
|
|
1351
1401
|
// No cache: gate freshness > the ~5ms HTTP roundtrip. Otherwise an attach
|
|
@@ -1643,9 +1693,11 @@ if (!isRemote) tool('auth', 'Sign in to Drafted. On a local install the DESKTOP
|
|
|
1643
1693
|
console.error(`\n[MCP] Sign in at: ${verificationUrl}\n${qrText ? qrText + '\n' : ''}[MCP] Waiting for approval...`);
|
|
1644
1694
|
|
|
1645
1695
|
if (!reusingPending) {
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
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]);
|
|
1649
1701
|
}
|
|
1650
1702
|
|
|
1651
1703
|
const deadline = Date.now() + (expiresIn * 1000);
|
|
@@ -1912,12 +1964,15 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
|
|
|
1912
1964
|
if (g3) return err(new Error(g3));
|
|
1913
1965
|
const { name, description, templateSlug, org } = args;
|
|
1914
1966
|
if (!name) throw new Error('name required for action=create');
|
|
1967
|
+
// Don't silently create the project in whatever org the session inherited.
|
|
1968
|
+
await requireBoundOrgForProjectlessMutation(org);
|
|
1915
1969
|
const body = { name };
|
|
1916
1970
|
if (description) body.description = description;
|
|
1917
1971
|
if (templateSlug) body.templateSlug = templateSlug;
|
|
1918
1972
|
// `org` targets a specific org without switching the active org.
|
|
1919
1973
|
const createExtra = org ? { 'X-Drafted-Org': org } : {};
|
|
1920
|
-
|
|
1974
|
+
const created = await api('POST', '/api/projects', body, createExtra);
|
|
1975
|
+
return ok({ ...withProjectBreadcrumb(created), ...(await orgEcho(created, org)) });
|
|
1921
1976
|
}
|
|
1922
1977
|
case 'update': {
|
|
1923
1978
|
const { projectId, name, folder, description, layers } = args;
|
|
@@ -2037,10 +2092,13 @@ tool('template', 'Manage project templates in an org. Dispatch by `action`: list
|
|
|
2037
2092
|
case 'create': {
|
|
2038
2093
|
const { name, description, layers, skillSlugs, visibility } = args;
|
|
2039
2094
|
if (!name || !description || !layers) throw new Error('name, description, layers required for action=create');
|
|
2095
|
+
// Don't silently create the template in whatever org the session inherited.
|
|
2096
|
+
await requireBoundOrgForProjectlessMutation(args.org);
|
|
2040
2097
|
const body = { name, description, layers };
|
|
2041
2098
|
if (Array.isArray(skillSlugs)) body.skillSlugs = skillSlugs;
|
|
2042
2099
|
if (visibility) body.visibility = visibility;
|
|
2043
|
-
|
|
2100
|
+
const createdTpl = await api('POST', '/api/templates', body, orgHeader);
|
|
2101
|
+
return ok({ ...createdTpl, ...(await orgEcho(createdTpl, args.org)) });
|
|
2044
2102
|
}
|
|
2045
2103
|
case 'update': {
|
|
2046
2104
|
const { templateId, name, description, layers, skillSlugs, visibility } = args;
|
|
@@ -2052,7 +2110,8 @@ tool('template', 'Manage project templates in an org. Dispatch by `action`: list
|
|
|
2052
2110
|
if (Array.isArray(skillSlugs)) body.skillSlugs = skillSlugs;
|
|
2053
2111
|
if (visibility) body.visibility = visibility;
|
|
2054
2112
|
if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
|
|
2055
|
-
|
|
2113
|
+
const updatedTpl = await api('PUT', `/api/templates/${templateId}`, body, orgHeader);
|
|
2114
|
+
return ok({ ...updatedTpl, ...(await orgEcho(updatedTpl, args.org)) });
|
|
2056
2115
|
}
|
|
2057
2116
|
case 'delete': {
|
|
2058
2117
|
const { templateId } = args;
|
|
@@ -2062,9 +2121,12 @@ tool('template', 'Manage project templates in an org. Dispatch by `action`: list
|
|
|
2062
2121
|
case 'fork': {
|
|
2063
2122
|
const { templateId, name } = args;
|
|
2064
2123
|
if (!templateId) throw new Error('templateId required for action=fork');
|
|
2124
|
+
// Forking creates a new template — same org rule as any create.
|
|
2125
|
+
await requireBoundOrgForProjectlessMutation(args.org);
|
|
2065
2126
|
const body = {};
|
|
2066
2127
|
if (name) body.name = name;
|
|
2067
|
-
|
|
2128
|
+
const forkedTpl = await api('POST', `/api/templates/${templateId}/fork`, body, orgHeader);
|
|
2129
|
+
return ok({ ...forkedTpl, ...(await orgEcho(forkedTpl, args.org)) });
|
|
2068
2130
|
}
|
|
2069
2131
|
default:
|
|
2070
2132
|
throw new Error(`Unknown template action: ${action}`);
|
|
@@ -2412,7 +2474,8 @@ async function getCachedMcpUpdateMetadata() {
|
|
|
2412
2474
|
|
|
2413
2475
|
|
|
2414
2476
|
tool('get_org', {
|
|
2415
|
-
action: z.enum(['get', 'update_mcp']).optional().describe('Default: "get" returns your orgs,
|
|
2477
|
+
action: z.enum(['get', 'update_mcp', 'use']).optional().describe('Default: "get" returns your orgs, this session\'s resolved working org, and Google Drive availability. "use" (with org=) sets THIS session\'s working org — a per-session, per-request DEFAULT for project-less creates/forks. It is NOT the retired sticky cursor: it never overrides a UUID-addressed resource, an explicit org=, or an open project, and it is private to this session (never a shared server row). "update_mcp" returns installed stdio MCP update instructions.'),
|
|
2478
|
+
org: z.string().optional().describe('[use] org id or name to set as this session\'s working-org default.'),
|
|
2416
2479
|
}, async (args = {}) => {
|
|
2417
2480
|
try {
|
|
2418
2481
|
const action = args.action || 'get';
|
|
@@ -2422,6 +2485,30 @@ tool('get_org', {
|
|
|
2422
2485
|
return ok(buildInstalledMcpUpdateInstructions(mcpUpdate));
|
|
2423
2486
|
}
|
|
2424
2487
|
|
|
2488
|
+
if (action === 'use') {
|
|
2489
|
+
// P3 (DRAFT-36): the explicit per-session working-org. Symmetric with what a remote
|
|
2490
|
+
// connection gets from its OAuth org — a stdio agent declares it here. Stored in
|
|
2491
|
+
// THIS session's bucket only (never a shared server row), injected as X-Drafted-Org
|
|
2492
|
+
// by api(), and only a DEFAULT: a UUID-addressed resource, an explicit org=, or an
|
|
2493
|
+
// open project always win. In-memory (not persisted) so it can never clobber another
|
|
2494
|
+
// process's boot state — the concurrency invariant holds by construction.
|
|
2495
|
+
const want = (args.org || '').trim();
|
|
2496
|
+
if (!want) throw new Error('org (id or name) required for action=use');
|
|
2497
|
+
const orgs = await getOrgList();
|
|
2498
|
+
const byId = orgs.find((o) => o.id === want);
|
|
2499
|
+
const byName = orgs.filter((o) => (o.name || '').toLowerCase() === want.toLowerCase());
|
|
2500
|
+
const hit = byId || (byName.length === 1 ? byName[0] : null);
|
|
2501
|
+
if (!hit) {
|
|
2502
|
+
const reason = byName.length > 1 ? `ambiguous org name "${want}"` : `not a member of org "${want}"`;
|
|
2503
|
+
throw new Error(`${reason}. Your orgs: ${orgs.map((o) => o.name || o.id).join(', ') || '(none resolvable)'}`);
|
|
2504
|
+
}
|
|
2505
|
+
getSessionState().boundOrgId = hit.id;
|
|
2506
|
+
return ok({
|
|
2507
|
+
workingOrg: { id: hit.id, name: hit.name },
|
|
2508
|
+
note: `Working org for this session is now "${hit.name}". Project-less creates/forks default here; a UUID-addressed resource, an explicit org=, or an open project still win. Not the retired switch — per-session and per-request, never a shared cursor.`,
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2425
2512
|
// Source of truth = the org this MCP process scopes requests to (what mutations
|
|
2426
2513
|
// will actually hit). Each MCP process is independent — multiple agents can run
|
|
2427
2514
|
// in parallel scoped to different orgs. /auth/me reads sessions.org_id directly.
|
|
@@ -2432,6 +2519,13 @@ tool('get_org', {
|
|
|
2432
2519
|
const orgs = (data.orgs || data || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
|
|
2433
2520
|
const activeOrg = sessionOrgId ? (orgs.find(o => o.id === sessionOrgId) || null) : null;
|
|
2434
2521
|
|
|
2522
|
+
// This session's WORKING org (P3): where project-less creates/forks land by default.
|
|
2523
|
+
// It's the per-session boundOrgId — set by an open project, a get_org(action="use"),
|
|
2524
|
+
// or (remote) the connection's org — not the shared session cursor. Announce it so an
|
|
2525
|
+
// agent can self-verify without guessing (invariant: "announced, never opaque").
|
|
2526
|
+
const workingOrgId = getSessionState().boundOrgId || null;
|
|
2527
|
+
const workingOrg = workingOrgId ? (orgs.find(o => o.id === workingOrgId) || { id: workingOrgId, name: null }) : null;
|
|
2528
|
+
|
|
2435
2529
|
const googleDrive = await getGoogleDriveAvailability();
|
|
2436
2530
|
const mcpUpdate = await getCachedMcpUpdateMetadata();
|
|
2437
2531
|
|
|
@@ -2444,13 +2538,14 @@ tool('get_org', {
|
|
|
2444
2538
|
}
|
|
2445
2539
|
return ok({
|
|
2446
2540
|
activeOrg,
|
|
2541
|
+
workingOrg,
|
|
2447
2542
|
orgs,
|
|
2448
2543
|
members: members.map(m => ({ id: m.userId, name: m.username, email: m.email, role: m.role })),
|
|
2449
2544
|
googleDrive,
|
|
2450
2545
|
mcpVersion: PACKAGE_VERSION,
|
|
2451
2546
|
mcpUpdate,
|
|
2452
2547
|
session: await sessionSurfaceBlock(),
|
|
2453
|
-
note: "Org is derived from the resource you address: opening a project binds this agent session's context (org = the project's org), and UUIDs (pageId/skillId/projectId) self-derive.
|
|
2548
|
+
note: "Org is derived from the resource you address: opening a project binds this agent session's context (org = the project's org), and UUIDs (pageId/skillId/projectId) self-derive. `workingOrg` is where project-less creates/forks land by default — set it explicitly with get_org(action=\"use\", org=...) when you're multi-org and working project-less; a UUID/explicit org=/open project always wins. It is per-session and per-request, never a shared cursor or a switch. `session` is THIS agent's own surface identity — `session.name` is the human-readable tab name the user sees (use it to identify which agent you are); refresh it via whoami. Concurrent MCP sessions can operate on different orgs simultaneously. If googleDrive.connected is true, strongly prefer Google Workspace frames for docs, sheets, and slides.",
|
|
2454
2549
|
});
|
|
2455
2550
|
} catch (error) { return err(error); }
|
|
2456
2551
|
});
|
|
@@ -3400,7 +3495,9 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
3400
3495
|
// bound org (or explicit org=) when the user is multi-org. (DRAFT-36 Phase 4.)
|
|
3401
3496
|
// UUID-first exception: update/remove addressed by a skillId derive their org
|
|
3402
3497
|
// from the skill row server-side, so the bound-org gate is unnecessary.
|
|
3403
|
-
|
|
3498
|
+
// `fork` (and the fork-on-403 branch inside `update`) run the same guard at
|
|
3499
|
+
// fork time, inside their cases — a fork is a create, governed by the one rule.
|
|
3500
|
+
if (['add', 'update', 'remove', 'push', 'import'].includes(action)) {
|
|
3404
3501
|
const byId = (action === 'update' || action === 'remove') && /^[a-f0-9-]{36}$/.test(args.skillId || '');
|
|
3405
3502
|
if (!byId) await requireBoundOrgForProjectlessMutation(args.org);
|
|
3406
3503
|
}
|
|
@@ -3478,7 +3575,8 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
3478
3575
|
// org names where the skill is born (UUID-first model: org is explicit at
|
|
3479
3576
|
// create) — without this the bound-org guard accepts org= but the write
|
|
3480
3577
|
// would land in the session org anyway.
|
|
3481
|
-
|
|
3578
|
+
const addedSkill = await api('POST', '/api/skills', body, org ? { 'X-Drafted-Org': org } : {});
|
|
3579
|
+
return ok({ ...addedSkill, ...(await orgEcho(addedSkill, org)) });
|
|
3482
3580
|
}
|
|
3483
3581
|
case 'update': {
|
|
3484
3582
|
// Auto-fork-on-update (tool behavior): if the target is read-only (global/
|
|
@@ -3502,9 +3600,14 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
3502
3600
|
if (Object.keys(body).length === 0) throw new Error('At least one field is required for action=update');
|
|
3503
3601
|
try {
|
|
3504
3602
|
const r = await api('PUT', `/api/skills/${id}`, body, extra);
|
|
3505
|
-
return ok({ ...r, forked: false });
|
|
3603
|
+
return ok({ ...r, forked: false, ...(await orgEcho(r, org)) });
|
|
3506
3604
|
} catch (e) {
|
|
3507
3605
|
if (e.code !== 'skill_read_only') throw e;
|
|
3606
|
+
// Read-only skill → the update becomes a FORK (a create). Its org must be a
|
|
3607
|
+
// real root: explicit org=, the active project, or a single-org user's only
|
|
3608
|
+
// org — else, multi-org with nothing bound, error rather than guess. The
|
|
3609
|
+
// receipt names the resulting org so a surprising fork is visible (Reading A).
|
|
3610
|
+
await requireBoundOrgForProjectlessMutation(org);
|
|
3508
3611
|
let forkId;
|
|
3509
3612
|
try {
|
|
3510
3613
|
forkId = (await api('POST', `/api/skills/${id}/fork`, {}, extra)).id;
|
|
@@ -3514,7 +3617,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
3514
3617
|
forkId = (await api('GET', `/api/skills/slug/${ownSlug}`, undefined, extra)).id;
|
|
3515
3618
|
}
|
|
3516
3619
|
const r2 = await api('PUT', `/api/skills/${forkId}`, body, extra);
|
|
3517
|
-
return ok({ ...r2, forked: true });
|
|
3620
|
+
return ok({ ...r2, forked: true, ...(await orgEcho(r2, org)) });
|
|
3518
3621
|
}
|
|
3519
3622
|
}
|
|
3520
3623
|
case 'remove': {
|
|
@@ -3530,8 +3633,12 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
3530
3633
|
let id = skillId;
|
|
3531
3634
|
if (!id && slugArg) { const s = await api('GET', `/api/skills/slug/${slugArg}`, undefined, extra); id = s.id; }
|
|
3532
3635
|
if (!id) throw new Error('skillId or skill (slug) required for action=fork');
|
|
3636
|
+
// Forking creates a new skill — same org rule as any create (explicit org=,
|
|
3637
|
+
// active project, or single-org; else refuse to guess). Receipt names the org.
|
|
3638
|
+
await requireBoundOrgForProjectlessMutation(org);
|
|
3533
3639
|
try {
|
|
3534
|
-
|
|
3640
|
+
const forkedSkill = await api('POST', `/api/skills/${id}/fork`, {}, extra);
|
|
3641
|
+
return ok({ ...forkedSkill, ...(await orgEcho(forkedSkill, org)) });
|
|
3535
3642
|
} catch (e) {
|
|
3536
3643
|
if (e.status === 409) return ok({ status: 'conflict', error: e.message }); // org already owns this slug
|
|
3537
3644
|
throw e;
|
|
@@ -3551,7 +3658,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
|
|
|
3551
3658
|
if (!Array.isArray(fileList) || fileList.length === 0) throw new Error('files[] (non-empty) or dir required for action=push');
|
|
3552
3659
|
const pushed = await api('POST', `/api/skills/${id}/files/bulk`, { files: fileList, deleteMissing: !!deleteMissing }, extra);
|
|
3553
3660
|
if (dir) { try { if (ensureSkillInstallIgnored(dir)) pushed.gitignored = '.skillinstall/'; } catch { /* best-effort */ } }
|
|
3554
|
-
return ok(pushed);
|
|
3661
|
+
return ok({ ...pushed, ...(await orgEcho(pushed, org)) });
|
|
3555
3662
|
}
|
|
3556
3663
|
case 'attach': {
|
|
3557
3664
|
const { skillId } = args;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Regression check for the MCP org-ambiguity policy (DRAFT-36 "one rule").
|
|
2
|
+
// Run: `node mcp/test-org-guards.mjs`. No framework — asserts the single pure
|
|
3
|
+
// decision core that the org guard delegates to, for BOTH creates and forks.
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { projectlessMutationNeedsOrg } from './server.mjs';
|
|
6
|
+
|
|
7
|
+
// One rule governs create AND fork (a fork is a create). A write proceeds when its
|
|
8
|
+
// org is a real root — explicit org=, a bound/active project, a remote-adopted org,
|
|
9
|
+
// or a single-org user's only org. It refuses to GUESS only when the user is
|
|
10
|
+
// multi-org with nothing bound.
|
|
11
|
+
assert.equal(projectlessMutationNeedsOrg({ explicitOrg: 'ee', orgCount: 5 }), false, 'explicit org → allow');
|
|
12
|
+
assert.equal(projectlessMutationNeedsOrg({ boundOrgId: 'causeway', orgCount: 5 }), false, 'bound project → allow (a real root, not the cursor)');
|
|
13
|
+
assert.equal(projectlessMutationNeedsOrg({ activeProjectId: 'p1', orgCount: 5 }), false, 'active project → allow');
|
|
14
|
+
assert.equal(projectlessMutationNeedsOrg({ isRemote: true, orgCount: 5 }), false, 'remote session → allow (adopts its own connection org)');
|
|
15
|
+
assert.equal(projectlessMutationNeedsOrg({ orgCount: 1 }), false, 'single org → allow');
|
|
16
|
+
assert.equal(projectlessMutationNeedsOrg({ orgCount: 0 }), false, 'unknown membership → allow (never block a legit write)');
|
|
17
|
+
assert.equal(projectlessMutationNeedsOrg({ orgCount: 3 }), true, 'multi-org, nothing bound → BLOCK (refuse to guess)');
|
|
18
|
+
|
|
19
|
+
// The xcode-build incident state: multi-org + a bound project sticky from an
|
|
20
|
+
// earlier, unrelated project(open). Under Reading A a fork is NOT hard-blocked —
|
|
21
|
+
// it lands in the active project's org (a real root) and the response RECEIPT names
|
|
22
|
+
// that org, so the fork is visible instead of silent. Create and fork agree here by
|
|
23
|
+
// design (one rule); the fix for the surprise is the receipt, not a block.
|
|
24
|
+
const incident = { orgCount: 3, boundOrgId: 'causeway', activeProjectId: 'p1' };
|
|
25
|
+
assert.equal(projectlessMutationNeedsOrg(incident), false, 'incident state: fork allowed into the bound org — awareness comes from the receipt (Reading A)');
|
|
26
|
+
|
|
27
|
+
// The genuinely ambiguous case still errors for a fork, exactly as for a create:
|
|
28
|
+
assert.equal(projectlessMutationNeedsOrg({ orgCount: 3 }), true, 'multi-org fork with nothing bound → BLOCK before any copy is created');
|
|
29
|
+
|
|
30
|
+
console.log('org-guard policy OK');
|
|
31
|
+
// Importing server.mjs builds the stdio MCP singleton, which opens a WS reconnect
|
|
32
|
+
// loop that keeps the event loop alive. Assertions are done — exit deterministically.
|
|
33
|
+
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.14.
|
|
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": [
|