@kodama-run/sdk 0.1.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.
- package/package.json +34 -0
- package/src/adapters/cli.ts +172 -0
- package/src/adapters/mcp.ts +677 -0
- package/src/adapters/openclaw.ts +231 -0
- package/src/crypto.ts +102 -0
- package/src/index.ts +9 -0
- package/src/permissions.ts +160 -0
- package/src/room.ts +399 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { Kodama } from "../room.ts";
|
|
4
|
+
import type { RoomMessage } from "@kodama-run/shared";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Types for OpenClaw skill interface
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
interface SkillContext {
|
|
11
|
+
/** OpenClaw provides this — agent context, memory, etc. */
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface KodamaSkillOptions {
|
|
16
|
+
relayUrl?: string;
|
|
17
|
+
agentName?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ActionParams =
|
|
21
|
+
| { action: "create"; relay_url?: string; password?: string }
|
|
22
|
+
| { action: "join"; code: string; name?: string; relay_url?: string }
|
|
23
|
+
| { action: "respond"; content: string }
|
|
24
|
+
| { action: "leave" };
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// State machine: idle → joined → in_turn → idle
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
type SkillState = "idle" | "joined" | "in_turn";
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Factory
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export function createKodamaSkill(options?: KodamaSkillOptions) {
|
|
37
|
+
const defaultRelay = options?.relayUrl ?? "http://localhost:8000";
|
|
38
|
+
const defaultName = options?.agentName ?? "OpenClaw Agent";
|
|
39
|
+
|
|
40
|
+
let agent: Kodama | null = null;
|
|
41
|
+
let state: SkillState = "idle";
|
|
42
|
+
let turnResolve: ((messages: RoomMessage[]) => void) | null = null;
|
|
43
|
+
let respondResolve: ((messages: RoomMessage[]) => void) | null = null;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
name: "kodama",
|
|
47
|
+
description:
|
|
48
|
+
"Join a Kodama negotiation room to converse with other agents. " +
|
|
49
|
+
"Actions: create, join, respond, leave.",
|
|
50
|
+
|
|
51
|
+
parameters: {
|
|
52
|
+
type: "object",
|
|
53
|
+
required: ["action"],
|
|
54
|
+
properties: {
|
|
55
|
+
action: {
|
|
56
|
+
type: "string",
|
|
57
|
+
enum: ["create", "join", "respond", "leave"],
|
|
58
|
+
description: "The operation to perform.",
|
|
59
|
+
},
|
|
60
|
+
code: { type: "string", description: "Room code (for join)." },
|
|
61
|
+
name: { type: "string", description: "Agent display name (for join)." },
|
|
62
|
+
relay_url: { type: "string", description: "Relay URL override." },
|
|
63
|
+
password: { type: "string", description: "Room password (for create/join)." },
|
|
64
|
+
content: { type: "string", description: "Message content (for respond)." },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async execute(params: ActionParams, _context?: SkillContext) {
|
|
69
|
+
switch (params.action) {
|
|
70
|
+
// -----------------------------------------------------------------
|
|
71
|
+
// CREATE — call the relay HTTP API to create a new room
|
|
72
|
+
// -----------------------------------------------------------------
|
|
73
|
+
case "create": {
|
|
74
|
+
const relay = params.relay_url ?? defaultRelay;
|
|
75
|
+
const body: Record<string, unknown> = {};
|
|
76
|
+
if (params.password) body.password = params.password;
|
|
77
|
+
|
|
78
|
+
const res = await fetch(`${relay}/api/rooms`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "Content-Type": "application/json" },
|
|
81
|
+
body: JSON.stringify(body),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
const text = await res.text();
|
|
86
|
+
throw new Error(`Failed to create room (HTTP ${res.status}): ${text}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const data = (await res.json()) as { code: string };
|
|
90
|
+
const wsBase = relay
|
|
91
|
+
.replace(/^http:\/\//, "ws://")
|
|
92
|
+
.replace(/^https:\/\//, "wss://");
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
code: data.code,
|
|
96
|
+
relay_ws: `${wsBase}/ws`,
|
|
97
|
+
password: params.password ?? null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// -----------------------------------------------------------------
|
|
102
|
+
// JOIN — connect to a room and wait for the first turn
|
|
103
|
+
// -----------------------------------------------------------------
|
|
104
|
+
case "join": {
|
|
105
|
+
if (state !== "idle") {
|
|
106
|
+
throw new Error(`Cannot join: already in state "${state}". Leave first.`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const relay = params.relay_url ?? defaultRelay;
|
|
110
|
+
const wsUrl = relay.startsWith("ws")
|
|
111
|
+
? relay
|
|
112
|
+
: relay.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://") + "/ws";
|
|
113
|
+
|
|
114
|
+
agent = new Kodama(params.code, {
|
|
115
|
+
relayUrl: wsUrl,
|
|
116
|
+
name: params.name ?? defaultName,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
agent.on("error", (err) => {
|
|
120
|
+
// Surface errors to any pending promise
|
|
121
|
+
turnResolve = null;
|
|
122
|
+
respondResolve = null;
|
|
123
|
+
throw err;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
agent.on("turn", (messages) => {
|
|
127
|
+
state = "in_turn";
|
|
128
|
+
// If we're waiting for a turn (from join or respond), resolve it
|
|
129
|
+
if (turnResolve) {
|
|
130
|
+
turnResolve(messages);
|
|
131
|
+
turnResolve = null;
|
|
132
|
+
}
|
|
133
|
+
if (respondResolve) {
|
|
134
|
+
respondResolve(messages);
|
|
135
|
+
respondResolve = null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Return a promise that blocks until the agent calls respond()
|
|
139
|
+
return new Promise<string>((resolve) => {
|
|
140
|
+
// Stash resolve so respond() can call it
|
|
141
|
+
(agent as any).__respondResolve = resolve;
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await agent.join();
|
|
146
|
+
state = "joined";
|
|
147
|
+
|
|
148
|
+
// Wait for the first turn signal
|
|
149
|
+
const messages = await new Promise<RoomMessage[]>((resolve) => {
|
|
150
|
+
turnResolve = resolve;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
status: "joined",
|
|
155
|
+
room: params.code,
|
|
156
|
+
messages: messages.map((m) => ({
|
|
157
|
+
agent: m.agent.name,
|
|
158
|
+
content: m.content,
|
|
159
|
+
round: m.round,
|
|
160
|
+
turn: m.turn,
|
|
161
|
+
})),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// -----------------------------------------------------------------
|
|
166
|
+
// RESPOND — send a message and wait for the next turn (or completion)
|
|
167
|
+
// -----------------------------------------------------------------
|
|
168
|
+
case "respond": {
|
|
169
|
+
if (state !== "in_turn" || !agent) {
|
|
170
|
+
throw new Error(`Cannot respond: not in a turn (state="${state}").`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const resolve = (agent as any).__respondResolve as
|
|
174
|
+
| ((v: string) => void)
|
|
175
|
+
| undefined;
|
|
176
|
+
if (!resolve) {
|
|
177
|
+
throw new Error("No pending turn to respond to.");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Resolve the turn handler promise so the SDK sends the message
|
|
181
|
+
resolve(params.content);
|
|
182
|
+
(agent as any).__respondResolve = null;
|
|
183
|
+
state = "joined";
|
|
184
|
+
|
|
185
|
+
// Wait for the next turn or room completion
|
|
186
|
+
const room = agent.getRoom();
|
|
187
|
+
if (room?.status === "completed") {
|
|
188
|
+
return { status: "completed" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const messages = await new Promise<RoomMessage[]>((resolve) => {
|
|
192
|
+
turnResolve = resolve;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
status: "your_turn",
|
|
197
|
+
messages: messages.map((m) => ({
|
|
198
|
+
agent: m.agent.name,
|
|
199
|
+
content: m.content,
|
|
200
|
+
round: m.round,
|
|
201
|
+
turn: m.turn,
|
|
202
|
+
})),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// -----------------------------------------------------------------
|
|
207
|
+
// LEAVE — disconnect from the room
|
|
208
|
+
// -----------------------------------------------------------------
|
|
209
|
+
case "leave": {
|
|
210
|
+
if (agent) {
|
|
211
|
+
agent.leave();
|
|
212
|
+
agent = null;
|
|
213
|
+
}
|
|
214
|
+
state = "idle";
|
|
215
|
+
turnResolve = null;
|
|
216
|
+
respondResolve = null;
|
|
217
|
+
return { status: "left" };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
default:
|
|
221
|
+
throw new Error(`Unknown action: ${(params as any).action}`);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Default skill instance
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
export const kodamaSkill = createKodamaSkill();
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AES-256-GCM encryption via Web Crypto API (Excalidraw pattern).
|
|
3
|
+
*
|
|
4
|
+
* Keys are exported/imported as base64 strings for easy sharing
|
|
5
|
+
* (e.g. via URL fragment).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ALGO = "AES-GCM";
|
|
9
|
+
const KEY_LENGTH = 256;
|
|
10
|
+
const IV_LENGTH = 12; // 96-bit IV recommended for GCM
|
|
11
|
+
|
|
12
|
+
function toBase64(buffer: ArrayBuffer): string {
|
|
13
|
+
const bytes = new Uint8Array(buffer);
|
|
14
|
+
let binary = "";
|
|
15
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
16
|
+
binary += String.fromCharCode(bytes[i]);
|
|
17
|
+
}
|
|
18
|
+
return btoa(binary);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fromBase64(b64: string): Uint8Array {
|
|
22
|
+
const binary = atob(b64);
|
|
23
|
+
const bytes = new Uint8Array(binary.length);
|
|
24
|
+
for (let i = 0; i < binary.length; i++) {
|
|
25
|
+
bytes[i] = binary.charCodeAt(i);
|
|
26
|
+
}
|
|
27
|
+
return bytes;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class KodamaCrypto {
|
|
31
|
+
/**
|
|
32
|
+
* Generate a new AES-256-GCM key and export as base64.
|
|
33
|
+
*/
|
|
34
|
+
static async generateKey(): Promise<string> {
|
|
35
|
+
const key = await crypto.subtle.generateKey(
|
|
36
|
+
{ name: ALGO, length: KEY_LENGTH },
|
|
37
|
+
true, // extractable
|
|
38
|
+
["encrypt", "decrypt"],
|
|
39
|
+
);
|
|
40
|
+
const raw = await crypto.subtle.exportKey("raw", key);
|
|
41
|
+
return toBase64(raw);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Encrypt plaintext with AES-256-GCM.
|
|
46
|
+
* Returns base64-encoded ciphertext and IV.
|
|
47
|
+
*/
|
|
48
|
+
static async encrypt(
|
|
49
|
+
keyBase64: string,
|
|
50
|
+
plaintext: string,
|
|
51
|
+
): Promise<{ ciphertext: string; iv: string }> {
|
|
52
|
+
const key = await KodamaCrypto.importKey(keyBase64);
|
|
53
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
54
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
55
|
+
|
|
56
|
+
const cipherBuffer = await crypto.subtle.encrypt(
|
|
57
|
+
{ name: ALGO, iv },
|
|
58
|
+
key,
|
|
59
|
+
encoded,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
ciphertext: toBase64(cipherBuffer),
|
|
64
|
+
iv: toBase64(iv.buffer),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Decrypt AES-256-GCM ciphertext back to plaintext.
|
|
70
|
+
*/
|
|
71
|
+
static async decrypt(
|
|
72
|
+
keyBase64: string,
|
|
73
|
+
ciphertextBase64: string,
|
|
74
|
+
ivBase64: string,
|
|
75
|
+
): Promise<string> {
|
|
76
|
+
const key = await KodamaCrypto.importKey(keyBase64);
|
|
77
|
+
const iv = fromBase64(ivBase64);
|
|
78
|
+
const ciphertext = fromBase64(ciphertextBase64);
|
|
79
|
+
|
|
80
|
+
const plainBuffer = await crypto.subtle.decrypt(
|
|
81
|
+
{ name: ALGO, iv: iv as BufferSource },
|
|
82
|
+
key,
|
|
83
|
+
ciphertext as BufferSource,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return new TextDecoder().decode(plainBuffer);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Import a base64 key string as a CryptoKey.
|
|
91
|
+
*/
|
|
92
|
+
private static async importKey(keyBase64: string): Promise<CryptoKey> {
|
|
93
|
+
const raw = fromBase64(keyBase64);
|
|
94
|
+
return crypto.subtle.importKey(
|
|
95
|
+
"raw",
|
|
96
|
+
raw as BufferSource,
|
|
97
|
+
{ name: ALGO, length: KEY_LENGTH },
|
|
98
|
+
false,
|
|
99
|
+
["encrypt", "decrypt"],
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { Kodama } from "./room.ts";
|
|
2
|
+
export type { KodamaOptions, TurnContext } from "./room.ts";
|
|
3
|
+
export { KodamaCrypto } from "./crypto.ts";
|
|
4
|
+
export { PermissionChecker } from "./permissions.ts";
|
|
5
|
+
export type { PermissionConfig } from "./permissions.ts";
|
|
6
|
+
|
|
7
|
+
// MCP adapter — standalone stdio server, not a library import.
|
|
8
|
+
// Run directly: bun packages/sdk/src/adapters/mcp.ts
|
|
9
|
+
// See: ./adapters/mcp.ts
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { AUTO_DENY_PATTERNS } from "@kodama-run/shared";
|
|
3
|
+
|
|
4
|
+
export interface PermissionConfig {
|
|
5
|
+
allow?: string[];
|
|
6
|
+
deny?: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert a glob pattern to a RegExp.
|
|
11
|
+
* ** = any path (including slashes)
|
|
12
|
+
* * = any non-slash segment
|
|
13
|
+
* . = literal dot
|
|
14
|
+
*/
|
|
15
|
+
function globToRegex(pattern: string): RegExp {
|
|
16
|
+
let re = "";
|
|
17
|
+
let i = 0;
|
|
18
|
+
while (i < pattern.length) {
|
|
19
|
+
const ch = pattern[i];
|
|
20
|
+
if (ch === "*" && pattern[i + 1] === "*") {
|
|
21
|
+
// ** matches anything including path separators
|
|
22
|
+
re += ".*";
|
|
23
|
+
i += 2;
|
|
24
|
+
// Skip trailing slash after ** (e.g. **/)
|
|
25
|
+
if (pattern[i] === "/") i++;
|
|
26
|
+
} else if (ch === "*") {
|
|
27
|
+
// * matches anything except /
|
|
28
|
+
re += "[^/]*";
|
|
29
|
+
i++;
|
|
30
|
+
} else if (ch === "?") {
|
|
31
|
+
re += "[^/]";
|
|
32
|
+
i++;
|
|
33
|
+
} else if (ch === ".") {
|
|
34
|
+
re += "\\.";
|
|
35
|
+
i++;
|
|
36
|
+
} else {
|
|
37
|
+
re += ch;
|
|
38
|
+
i++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return new RegExp(`^${re}$`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function matchesAny(filePath: string, patterns: string[]): boolean {
|
|
45
|
+
return patterns.some((pattern) => globToRegex(pattern).test(filePath));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Enforces .kodama permission files.
|
|
50
|
+
*
|
|
51
|
+
* - If an explicit allow list exists, a file must match allow AND not match deny.
|
|
52
|
+
* - If no allow list, allow everything except deny.
|
|
53
|
+
* - AUTO_DENY_PATTERNS are always appended to the deny list.
|
|
54
|
+
*/
|
|
55
|
+
export class PermissionChecker {
|
|
56
|
+
private readonly allowPatterns: string[] | null;
|
|
57
|
+
private readonly denyPatterns: string[];
|
|
58
|
+
|
|
59
|
+
constructor(config?: PermissionConfig) {
|
|
60
|
+
this.allowPatterns = config?.allow?.length ? config.allow : null;
|
|
61
|
+
this.denyPatterns = [...(config?.deny ?? []), ...AUTO_DENY_PATTERNS];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if a file path is allowed by the permission rules.
|
|
66
|
+
* Tests against the basename for deny patterns that don't contain slashes,
|
|
67
|
+
* and against the full path for patterns that do.
|
|
68
|
+
*/
|
|
69
|
+
isAllowed(filePath: string): boolean {
|
|
70
|
+
// Check deny list — match against full path AND basename
|
|
71
|
+
const basename = filePath.split("/").pop() ?? filePath;
|
|
72
|
+
for (const pattern of this.denyPatterns) {
|
|
73
|
+
// If pattern has no slash, match against basename; otherwise match full path
|
|
74
|
+
if (!pattern.includes("/")) {
|
|
75
|
+
if (globToRegex(pattern).test(basename)) return false;
|
|
76
|
+
} else {
|
|
77
|
+
if (globToRegex(pattern).test(filePath)) return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// If allow list exists, file must match at least one allow pattern
|
|
82
|
+
if (this.allowPatterns !== null) {
|
|
83
|
+
return matchesAny(filePath, this.allowPatterns);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Content-level message filtering (pass-through for now).
|
|
91
|
+
*/
|
|
92
|
+
filterMessage(content: string): string {
|
|
93
|
+
return content;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse a simple YAML permission file format.
|
|
98
|
+
*
|
|
99
|
+
* Expected format:
|
|
100
|
+
* ```
|
|
101
|
+
* allow:
|
|
102
|
+
* - src/**
|
|
103
|
+
* - docs/*.md
|
|
104
|
+
* deny:
|
|
105
|
+
* - "*.log"
|
|
106
|
+
* - tmp/**
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
static fromYaml(content: string): PermissionChecker {
|
|
110
|
+
const lines = content.split("\n");
|
|
111
|
+
const allow: string[] = [];
|
|
112
|
+
const deny: string[] = [];
|
|
113
|
+
let currentSection: "allow" | "deny" | null = null;
|
|
114
|
+
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
const trimmed = line.trim();
|
|
117
|
+
if (trimmed === "allow:") {
|
|
118
|
+
currentSection = "allow";
|
|
119
|
+
} else if (trimmed === "deny:") {
|
|
120
|
+
currentSection = "deny";
|
|
121
|
+
} else if (trimmed.startsWith("- ") && currentSection) {
|
|
122
|
+
// Strip the "- " prefix and any surrounding quotes
|
|
123
|
+
let value = trimmed.slice(2).trim();
|
|
124
|
+
if (
|
|
125
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
126
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
127
|
+
) {
|
|
128
|
+
value = value.slice(1, -1);
|
|
129
|
+
}
|
|
130
|
+
if (currentSection === "allow") {
|
|
131
|
+
allow.push(value);
|
|
132
|
+
} else {
|
|
133
|
+
deny.push(value);
|
|
134
|
+
}
|
|
135
|
+
} else if (trimmed !== "" && !trimmed.startsWith("#")) {
|
|
136
|
+
// Non-empty, non-comment line that isn't a list item — reset section
|
|
137
|
+
currentSection = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return new PermissionChecker({
|
|
142
|
+
allow: allow.length > 0 ? allow : undefined,
|
|
143
|
+
deny: deny.length > 0 ? deny : undefined,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read a .kodama permission file and return a checker.
|
|
149
|
+
* Returns a permissive default if the file doesn't exist.
|
|
150
|
+
*/
|
|
151
|
+
static async fromFile(path: string): Promise<PermissionChecker> {
|
|
152
|
+
try {
|
|
153
|
+
const content = await readFile(path, "utf-8");
|
|
154
|
+
return PermissionChecker.fromYaml(content);
|
|
155
|
+
} catch {
|
|
156
|
+
// File doesn't exist or can't be read — return permissive default
|
|
157
|
+
return new PermissionChecker();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|