@athsra/cli 1.0.4 → 1.1.1
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/README.md +34 -10
- package/package.json +3 -3
- package/src/commands/delete.ts +16 -13
- package/src/commands/get.ts +8 -5
- package/src/commands/handoff.ts +13 -3
- package/src/commands/login.ts +208 -61
- package/src/commands/logout.ts +3 -2
- package/src/commands/ls.ts +32 -10
- package/src/commands/manifest.ts +2 -2
- package/src/commands/mcp.ts +259 -7
- package/src/commands/migrate-envelopes.ts +55 -3
- package/src/commands/purge.ts +13 -10
- package/src/commands/restore.ts +10 -6
- package/src/commands/rollback.ts +12 -9
- package/src/commands/rotate-master.ts +13 -13
- package/src/commands/run.ts +6 -24
- package/src/commands/service-token.ts +15 -31
- package/src/commands/set.ts +7 -6
- package/src/commands/unset.ts +11 -8
- package/src/commands/versions.ts +7 -5
- package/src/index.ts +12 -8
- package/src/lib/auth-context.ts +77 -13
- package/src/lib/auth-proof.ts +26 -0
- package/src/lib/auto-project.ts +58 -14
- package/src/lib/client.ts +112 -19
- package/src/lib/config.ts +2 -0
- package/src/lib/device-login.ts +157 -0
- package/src/lib/env-format.ts +1 -1
- package/src/lib/envelope.ts +105 -15
- package/src/lib/identity-key.ts +21 -0
- package/src/lib/keyring.ts +25 -0
- package/src/lib/mcp-register.ts +223 -0
- package/src/lib/mcp-tools/admin.ts +267 -0
- package/src/lib/mcp-tools/args.ts +26 -0
- package/src/lib/mcp-tools/confirm.ts +21 -0
- package/src/lib/mcp-tools/defs.ts +388 -3
- package/src/lib/mcp-tools/login.ts +156 -0
- package/src/lib/mcp-tools/mask.ts +41 -0
- package/src/lib/mcp-tools/read.ts +115 -1
- package/src/lib/mcp-tools/result.ts +5 -5
- package/src/lib/mcp-tools/run.ts +101 -0
- package/src/lib/mcp-tools/write.ts +84 -5
- package/src/lib/oidc-flow.ts +43 -1
- package/src/lib/org-rewrap.ts +9 -3
- package/src/lib/service-tokens.ts +62 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-tools/admin.ts — Phase 5 B4. admin tier 핸들러 (`ATHSRA_MCP_ADMIN=1` opt-in 시에만 dispatch).
|
|
3
|
+
*
|
|
4
|
+
* 7 도구: org_invite / org_remove_member / project_share / project_unshare /
|
|
5
|
+
* service_token_create / service_token_revoke / purge.
|
|
6
|
+
*
|
|
7
|
+
* 핸들러 순서 (write.ts 패턴): args 검증 → requireConfirm(destructive, auth 로드 **전** 조기 차단)
|
|
8
|
+
* → loadAuthContext → kind 가드 → client 호출 → jsonOk.
|
|
9
|
+
*
|
|
10
|
+
* kind 가드 2단 (epic D-2):
|
|
11
|
+
* - **master pw 소비 3종** (org_remove_member 의 owner DEK 회전 / project_share 의 envelope
|
|
12
|
+
* 재포장 / service_token_create 의 recipient wrap) 은 user(master pw) 전용 — identity
|
|
13
|
+
* 디바이스에선 masterPwDenied 로 actionable 거부 (master pw 는 그 머신에 영원히 없음).
|
|
14
|
+
* - 나머지 4종은 user|identity (둘 다 user-급 토큰) — service token 은 7종 전부 거부.
|
|
15
|
+
*
|
|
16
|
+
* 시크릿 값 무노출 (epic D-1) — 유일 예외는 service_token_create 의 1회성 ats_* 반환
|
|
17
|
+
* (ONE-TIME EXPOSURE warning 동반). 그 외 응답은 메타데이터만.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { loadAuthContext } from '../auth-context.ts';
|
|
21
|
+
import type { AthsraClient } from '../client.ts';
|
|
22
|
+
import { grantOrgAccess, rotateAfterRemoval } from '../org-rewrap.ts';
|
|
23
|
+
import { createServiceTokenWithRecipient } from '../service-tokens.ts';
|
|
24
|
+
import { readInteger, readString } from './args.ts';
|
|
25
|
+
import { requireConfirm } from './confirm.ts';
|
|
26
|
+
import { jsonError, jsonOk, notLoggedIn, type ToolTextResult } from './result.ts';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* master pw 소비 도구의 비-user ctx 거부 메시지. identity 디바이스는 설계상 master pw 가 절대
|
|
30
|
+
* 없으므로(D-2) "어디서 실행해야 하는지" 를 안내 — AI 가 다음 행동을 고를 수 있게.
|
|
31
|
+
*/
|
|
32
|
+
export function masterPwDenied(tool: string, kind: 'identity' | 'service'): ToolTextResult {
|
|
33
|
+
if (kind === 'identity') {
|
|
34
|
+
return jsonError(
|
|
35
|
+
`${tool} consumes the master password (envelope DEK re-wrap), which never exists on this ` +
|
|
36
|
+
'identity-mode device by design. Run it on a master-pw machine (`athsra login --password`) ' +
|
|
37
|
+
'or use the dashboard — the browser is where the master password lives.',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return jsonError(
|
|
41
|
+
`${tool} requires a user-level token — scoped service tokens cannot perform admin operations.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** identifier(이메일 등) 또는 숫자 문자열 → 현재 org 멤버 user_id (org.ts grantAccessCmd 미러). */
|
|
46
|
+
async function resolveMemberUserId(
|
|
47
|
+
client: AthsraClient,
|
|
48
|
+
identifier: string,
|
|
49
|
+
): Promise<number | { error: string }> {
|
|
50
|
+
const direct = Number(identifier);
|
|
51
|
+
if (Number.isInteger(direct) && direct > 0) return direct;
|
|
52
|
+
const { members } = await client.listOrgMembers();
|
|
53
|
+
const member = members.find((m) => m.identifier === identifier);
|
|
54
|
+
if (!member) {
|
|
55
|
+
return {
|
|
56
|
+
error: `'${identifier}' is not a member of the current org — use athsra_org_info to list members`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return member.user_id;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 현재 org 에서의 내 role (org.ts currentOrgRole 미러 — commands→lib 역참조 회피용 재구현). */
|
|
63
|
+
async function currentOrgRole(client: AthsraClient): Promise<'owner' | 'admin' | 'member' | null> {
|
|
64
|
+
const { orgs } = await client.listOrgs();
|
|
65
|
+
return orgs.find((o) => o.is_current)?.role ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** athsra_org_invite — 현재 org 에 멤버 초대 (JIT pending user 생성 가능). 토큰-레벨 op. */
|
|
69
|
+
export async function handleOrgInvite(
|
|
70
|
+
args: Record<string, unknown> | undefined,
|
|
71
|
+
): Promise<ToolTextResult> {
|
|
72
|
+
const identifier = readString(args, 'identifier');
|
|
73
|
+
if (!identifier) return jsonError('missing required arg: identifier (email)');
|
|
74
|
+
const role = readString(args, 'role') ?? 'member';
|
|
75
|
+
if (role !== 'member' && role !== 'admin') {
|
|
76
|
+
return jsonError("invalid 'role' — must be 'member' or 'admin'");
|
|
77
|
+
}
|
|
78
|
+
const ctx = await loadAuthContext();
|
|
79
|
+
if (!ctx) return notLoggedIn();
|
|
80
|
+
if (ctx.kind === 'service') return masterPwDenied('org_invite', 'service');
|
|
81
|
+
const res = await ctx.client.inviteMember({ identifier, role });
|
|
82
|
+
return jsonOk({
|
|
83
|
+
...res,
|
|
84
|
+
next:
|
|
85
|
+
'invitee: `athsra login` then `athsra org use <slug>` to accept. Envelope access is ' +
|
|
86
|
+
'separate — share per project with athsra_project_share, or all projects via CLI ' +
|
|
87
|
+
'`athsra org grant-access`.',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* athsra_org_remove_member — 멤버 제거 + (owner 호출 시) DEK 회전 스윕 (real revocation).
|
|
93
|
+
* confirm=<identifier> 정확일치. master pw 소비 (owner 회전 경로) — identity 거부.
|
|
94
|
+
*/
|
|
95
|
+
export async function handleOrgRemoveMember(
|
|
96
|
+
args: Record<string, unknown> | undefined,
|
|
97
|
+
): Promise<ToolTextResult> {
|
|
98
|
+
const identifier = readString(args, 'identifier');
|
|
99
|
+
if (!identifier) {
|
|
100
|
+
return jsonError(
|
|
101
|
+
'missing required arg: identifier (member email or user id — athsra_org_info)',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const denied = requireConfirm(args, identifier);
|
|
105
|
+
if (denied) return denied;
|
|
106
|
+
const ctx = await loadAuthContext();
|
|
107
|
+
if (!ctx) return notLoggedIn();
|
|
108
|
+
if (ctx.kind !== 'user') return masterPwDenied('org_remove_member', ctx.kind);
|
|
109
|
+
const resolved = await resolveMemberUserId(ctx.client, identifier);
|
|
110
|
+
if (typeof resolved !== 'number') return jsonError(resolved.error);
|
|
111
|
+
const res = await ctx.client.removeMember(resolved);
|
|
112
|
+
// owner 면 DEK 회전 (org.ts removeCmd 미러) — 제거 멤버의 캐시 DEK 무효화.
|
|
113
|
+
const role = await currentOrgRole(ctx.client);
|
|
114
|
+
if (role === 'owner') {
|
|
115
|
+
const rot = await rotateAfterRemoval(ctx.client, ctx.masterPw, resolved);
|
|
116
|
+
return jsonOk({ ...res, dek_rotation: rot });
|
|
117
|
+
}
|
|
118
|
+
return jsonOk({
|
|
119
|
+
...res,
|
|
120
|
+
dek_rotation:
|
|
121
|
+
'skipped — only the org owner can rotate DEKs (master re-wrap). Have the owner run ' +
|
|
122
|
+
`\`athsra org rotate-after-removal ${resolved}\` for cryptographic revocation.`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* athsra_project_share — 단일 project 를 멤버에 공유: worker ACL(read|write) + envelope 멤버
|
|
128
|
+
* recipient 재포장 (grantOrgAccess projects 필터). master pw 소비 — identity 거부.
|
|
129
|
+
*/
|
|
130
|
+
export async function handleProjectShare(
|
|
131
|
+
args: Record<string, unknown> | undefined,
|
|
132
|
+
): Promise<ToolTextResult> {
|
|
133
|
+
const project = readString(args, 'project');
|
|
134
|
+
const identifier = readString(args, 'identifier');
|
|
135
|
+
if (!project || !identifier) return jsonError('missing required args: project, identifier');
|
|
136
|
+
const perms = readString(args, 'perms') ?? 'read';
|
|
137
|
+
if (perms !== 'read' && perms !== 'write') {
|
|
138
|
+
return jsonError("invalid 'perms' — must be 'read' or 'write'");
|
|
139
|
+
}
|
|
140
|
+
const ctx = await loadAuthContext();
|
|
141
|
+
if (!ctx) return notLoggedIn();
|
|
142
|
+
if (ctx.kind !== 'user') return masterPwDenied('project_share', ctx.kind);
|
|
143
|
+
const resolved = await resolveMemberUserId(ctx.client, identifier);
|
|
144
|
+
if (typeof resolved !== 'number') return jsonError(resolved.error);
|
|
145
|
+
await ctx.client.grantAcl({ project, userId: resolved, perms });
|
|
146
|
+
const rewrap = await grantOrgAccess(ctx.client, ctx.masterPw, resolved, {
|
|
147
|
+
projects: [project],
|
|
148
|
+
});
|
|
149
|
+
return jsonOk({
|
|
150
|
+
project,
|
|
151
|
+
user_id: resolved,
|
|
152
|
+
perms,
|
|
153
|
+
acl: 'granted',
|
|
154
|
+
rewrap,
|
|
155
|
+
...(rewrap.legacyV1.length > 0
|
|
156
|
+
? {
|
|
157
|
+
note: 'v1 envelope cannot take member recipients — run `athsra migrate-envelopes` first, then re-share.',
|
|
158
|
+
}
|
|
159
|
+
: {}),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* athsra_project_unshare — 멤버 ACL 철회 (API 접근 즉시 차단). 한계 정직 명시: DEK/recipient
|
|
165
|
+
* 미회전 — 캐시 보유 멤버는 과거 버전 복호 가능. 암호학적 철회는 org_remove_member(owner).
|
|
166
|
+
*/
|
|
167
|
+
export async function handleProjectUnshare(
|
|
168
|
+
args: Record<string, unknown> | undefined,
|
|
169
|
+
): Promise<ToolTextResult> {
|
|
170
|
+
const project = readString(args, 'project');
|
|
171
|
+
const identifier = readString(args, 'identifier');
|
|
172
|
+
if (!project || !identifier) return jsonError('missing required args: project, identifier');
|
|
173
|
+
const ctx = await loadAuthContext();
|
|
174
|
+
if (!ctx) return notLoggedIn();
|
|
175
|
+
if (ctx.kind === 'service') return masterPwDenied('project_unshare', 'service');
|
|
176
|
+
const resolved = await resolveMemberUserId(ctx.client, identifier);
|
|
177
|
+
if (typeof resolved !== 'number') return jsonError(resolved.error);
|
|
178
|
+
await ctx.client.revokeAcl({ project, userId: resolved });
|
|
179
|
+
return jsonOk({
|
|
180
|
+
project,
|
|
181
|
+
user_id: resolved,
|
|
182
|
+
acl: 'revoked',
|
|
183
|
+
limitation:
|
|
184
|
+
'API access is blocked immediately, but the envelope DEK and member recipient are NOT ' +
|
|
185
|
+
'rotated — a member who already cached the envelope can still decrypt past versions. ' +
|
|
186
|
+
'For cryptographic revocation, the owner removes the member from the org ' +
|
|
187
|
+
'(athsra_org_remove_member), which rotates DEKs.',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* athsra_service_token_create — scoped service token 발급 + envelope recipient 추가.
|
|
193
|
+
* expires_days 필수 1..365 (MCP 발급 자격증명은 반드시 만료). 유일한 자격증명-반환 도구.
|
|
194
|
+
* master pw 소비 — identity 거부.
|
|
195
|
+
*/
|
|
196
|
+
export async function handleServiceTokenCreate(
|
|
197
|
+
args: Record<string, unknown> | undefined,
|
|
198
|
+
): Promise<ToolTextResult> {
|
|
199
|
+
const project = readString(args, 'project');
|
|
200
|
+
const label = readString(args, 'label');
|
|
201
|
+
if (!project || !label) return jsonError('missing required args: project, label');
|
|
202
|
+
const perms = readString(args, 'perms') ?? 'read';
|
|
203
|
+
if (perms !== 'read' && perms !== 'write') {
|
|
204
|
+
return jsonError("invalid 'perms' — must be 'read' or 'write'");
|
|
205
|
+
}
|
|
206
|
+
const expiresDays = readInteger(args, 'expires_days');
|
|
207
|
+
if (expiresDays === undefined || expiresDays < 1 || expiresDays > 365) {
|
|
208
|
+
return jsonError(
|
|
209
|
+
'expires_days is required and must be an integer 1..365 — MCP-issued credentials always expire.',
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const ctx = await loadAuthContext();
|
|
213
|
+
if (!ctx) return notLoggedIn();
|
|
214
|
+
if (ctx.kind !== 'user') return masterPwDenied('service_token_create', ctx.kind);
|
|
215
|
+
const created = await createServiceTokenWithRecipient(ctx, {
|
|
216
|
+
project,
|
|
217
|
+
label,
|
|
218
|
+
perms,
|
|
219
|
+
expiresInDays: expiresDays,
|
|
220
|
+
});
|
|
221
|
+
return jsonOk({
|
|
222
|
+
...created,
|
|
223
|
+
warning:
|
|
224
|
+
'ONE-TIME EXPOSURE — this is the only athsra tool that returns a credential, and the ' +
|
|
225
|
+
'token is shown exactly once. Store it NOW (password manager / CI secret store), never ' +
|
|
226
|
+
'in code or chat logs. Headless usage: ATHSRA_TOKEN=ats_… athsra run -- <cmd>.',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** athsra_service_token_revoke — ats_* 즉시 무효화 (D1 strong consistency). 토큰-레벨 op. */
|
|
231
|
+
export async function handleServiceTokenRevoke(
|
|
232
|
+
args: Record<string, unknown> | undefined,
|
|
233
|
+
): Promise<ToolTextResult> {
|
|
234
|
+
const token = readString(args, 'token');
|
|
235
|
+
if (!token) return jsonError('missing required arg: token (ats_…)');
|
|
236
|
+
if (!token.startsWith('ats_')) return jsonError('service token must start with ats_');
|
|
237
|
+
const ctx = await loadAuthContext();
|
|
238
|
+
if (!ctx) return notLoggedIn();
|
|
239
|
+
if (ctx.kind === 'service') return masterPwDenied('service_token_revoke', 'service');
|
|
240
|
+
const res = await ctx.client.revoke(token);
|
|
241
|
+
return jsonOk({
|
|
242
|
+
ok: res.ok,
|
|
243
|
+
revoked: res.revoked,
|
|
244
|
+
note: 'worker auth rejects the token immediately; the stale envelope recipient entry left behind is harmless.',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** athsra_purge — hard delete (모든 versions+tombstone 영구 제거, 복구 불가). confirm=project. */
|
|
249
|
+
export async function handlePurge(
|
|
250
|
+
args: Record<string, unknown> | undefined,
|
|
251
|
+
): Promise<ToolTextResult> {
|
|
252
|
+
const project = readString(args, 'project');
|
|
253
|
+
if (!project) return jsonError('missing required arg: project');
|
|
254
|
+
const config = readString(args, 'config') ?? 'default';
|
|
255
|
+
const denied = requireConfirm(args, project);
|
|
256
|
+
if (denied) return denied;
|
|
257
|
+
const ctx = await loadAuthContext();
|
|
258
|
+
if (!ctx) return notLoggedIn();
|
|
259
|
+
if (ctx.kind === 'service') return masterPwDenied('purge', 'service');
|
|
260
|
+
const res = await ctx.client.deleteProject(project, { hard: true, config });
|
|
261
|
+
return jsonOk({
|
|
262
|
+
project,
|
|
263
|
+
action: 'purged',
|
|
264
|
+
removed_versions: res.removed_versions ?? 0,
|
|
265
|
+
warning: 'all version history permanently removed — NOT recoverable',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
@@ -37,6 +37,15 @@ export function readString(
|
|
|
37
37
|
return typeof v === 'string' ? v : undefined;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** args[key] 가 정수면 반환 (expires_days 등). 소수·문자열·NaN 은 undefined. */
|
|
41
|
+
export function readInteger(
|
|
42
|
+
args: Record<string, unknown> | undefined,
|
|
43
|
+
key: string,
|
|
44
|
+
): number | undefined {
|
|
45
|
+
const v = args?.[key];
|
|
46
|
+
return typeof v === 'number' && Number.isInteger(v) ? v : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
export function readBoolean(
|
|
41
50
|
args: Record<string, unknown> | undefined,
|
|
42
51
|
key: string,
|
|
@@ -58,3 +67,20 @@ export function readStringArray(
|
|
|
58
67
|
}
|
|
59
68
|
return out;
|
|
60
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* args[key] 가 string→string 레코드면 반환 (bulk_set 등). 값 중 하나라도 non-string 이면 undefined.
|
|
73
|
+
*/
|
|
74
|
+
export function readRecordOfStrings(
|
|
75
|
+
args: Record<string, unknown> | undefined,
|
|
76
|
+
key: string,
|
|
77
|
+
): Record<string, string> | undefined {
|
|
78
|
+
const v = args?.[key];
|
|
79
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) return undefined;
|
|
80
|
+
const out: Record<string, string> = {};
|
|
81
|
+
for (const [k, val] of Object.entries(v)) {
|
|
82
|
+
if (typeof val !== 'string') return undefined;
|
|
83
|
+
out[k] = val;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-tools/confirm.ts — Phase 5 B1. destructive admin tool 의 confirm 파라미터 검증.
|
|
3
|
+
*
|
|
4
|
+
* auth 로드(네트워크·keyring) 전에 호출 — 잘못된/누락된 confirm 을 조기 차단(불필요한 인증 회피
|
|
5
|
+
* + 실수 방지). args.confirm 이 expected(project·identifier 등 호출자가 명시해야 하는 값)와
|
|
6
|
+
* 정확히 일치해야 null(통과) 반환, 아니면 actionable error result.
|
|
7
|
+
*/
|
|
8
|
+
import { jsonError, type ToolTextResult } from './result.ts';
|
|
9
|
+
|
|
10
|
+
export function requireConfirm(
|
|
11
|
+
args: Record<string, unknown> | undefined,
|
|
12
|
+
expected: string,
|
|
13
|
+
): ToolTextResult | null {
|
|
14
|
+
const confirm = args?.confirm;
|
|
15
|
+
if (typeof confirm !== 'string' || confirm !== expected) {
|
|
16
|
+
return jsonError(
|
|
17
|
+
`confirmation required — pass confirm="${expected}" to proceed (irreversible/high-impact action).`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|