shabti 2.12.0 → 2.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shabti",
3
- "version": "2.12.0",
3
+ "version": "2.12.1",
4
4
  "description": "Agent Memory OS — semantic memory for AI agents",
5
5
  "type": "module",
6
6
  "main": "native.cjs",
package/src/a2a/server.js CHANGED
@@ -10,8 +10,13 @@ import { normalizeText } from "../utils/normalize.js";
10
10
  // ============================================================
11
11
 
12
12
  export class TaskStore {
13
- constructor() {
14
- this.tasks = new Map();
13
+ #tasks = new Map();
14
+ #maxTasks;
15
+ #ttlMs;
16
+
17
+ constructor({ maxTasks = 1000, ttlMs = 30 * 60 * 1000 } = {}) {
18
+ this.#maxTasks = maxTasks;
19
+ this.#ttlMs = ttlMs;
15
20
  }
16
21
 
17
22
  create(contextId) {
@@ -22,21 +27,44 @@ export class TaskStore {
22
27
  status: { state: "submitted", timestamp: new Date().toISOString() },
23
28
  artifacts: [],
24
29
  history: [],
30
+ createdAt: Date.now(),
25
31
  };
26
- this.tasks.set(task.id, task);
32
+ this.#tasks.set(task.id, task);
33
+
34
+ if (this.#tasks.size > this.#maxTasks) {
35
+ this.#evictOldest();
36
+ }
37
+
27
38
  return task;
28
39
  }
29
40
 
41
+ #evictOldest() {
42
+ const oldest = [...this.#tasks.entries()].sort(([, a], [, b]) => a.createdAt - b.createdAt)[0];
43
+ if (oldest) this.#tasks.delete(oldest[0]);
44
+ }
45
+
46
+ cleanup() {
47
+ const now = Date.now();
48
+ for (const [id, task] of this.#tasks) {
49
+ if (
50
+ now - task.createdAt > this.#ttlMs &&
51
+ ["completed", "canceled", "failed"].includes(task.status.state)
52
+ ) {
53
+ this.#tasks.delete(id);
54
+ }
55
+ }
56
+ }
57
+
30
58
  get(id) {
31
- return this.tasks.get(id) || null;
59
+ return this.#tasks.get(id) || null;
32
60
  }
33
61
 
34
62
  set(id, task) {
35
- this.tasks.set(id, task);
63
+ this.#tasks.set(id, task);
36
64
  }
37
65
 
38
66
  clear() {
39
- this.tasks.clear();
67
+ this.#tasks.clear();
40
68
  }
41
69
  }
42
70
 
@@ -403,10 +431,19 @@ function handleTasksCancel(taskStore, params) {
403
431
  // HTTP Server
404
432
  // ============================================================
405
433
 
406
- function readBody(req) {
434
+ function readBody(req, maxBytes = 1_048_576) {
435
+ // デフォルト1MB上限
407
436
  return new Promise((resolve, reject) => {
408
437
  const chunks = [];
409
- req.on("data", (c) => chunks.push(c));
438
+ let total = 0;
439
+ req.on("data", (c) => {
440
+ total += c.length;
441
+ if (total > maxBytes) {
442
+ req.destroy();
443
+ return reject(new Error("リクエストボディが大きすぎます"));
444
+ }
445
+ chunks.push(c);
446
+ });
410
447
  req.on("end", () => resolve(Buffer.concat(chunks).toString()));
411
448
  req.on("error", reject);
412
449
  });
@@ -434,17 +471,30 @@ export function startA2AServer(port = 3000) {
434
471
  const agentCard = buildAgentCard(baseUrl);
435
472
  const taskStore = new TaskStore();
436
473
 
474
+ const cleanupInterval = setInterval(() => taskStore.cleanup(), 5 * 60 * 1000);
475
+
437
476
  const server = createServer(async (req, res) => {
438
- // CORS headers
439
- res.setHeader("Access-Control-Allow-Origin", "*");
477
+ // CORS headers — restrict to null (no browser cross-origin access needed for localhost)
478
+ res.setHeader("Access-Control-Allow-Origin", "null");
440
479
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
441
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
480
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
442
481
 
443
482
  if (req.method === "OPTIONS") {
444
483
  res.writeHead(204);
445
484
  return res.end();
446
485
  }
447
486
 
487
+ // Bearer Token authentication
488
+ const a2aToken = process.env.SHABTI_A2A_TOKEN;
489
+ if (a2aToken) {
490
+ const authHeader = req.headers["authorization"] || "";
491
+ if (!authHeader.startsWith("Bearer ") || authHeader.slice(7) !== a2aToken) {
492
+ res.writeHead(401, { "Content-Type": "application/json" });
493
+ res.end(JSON.stringify({ error: "Unauthorized" }));
494
+ return;
495
+ }
496
+ }
497
+
448
498
  // Agent Card discovery
449
499
  if (req.method === "GET" && req.url === "/.well-known/agent-card.json") {
450
500
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -495,6 +545,8 @@ export function startA2AServer(port = 3000) {
495
545
  res.end("Not Found");
496
546
  });
497
547
 
548
+ server.on("close", () => clearInterval(cleanupInterval));
549
+
498
550
  server.listen(port, "127.0.0.1", () => {
499
551
  console.log(`\n Shabti A2A server listening on ${baseUrl}`);
500
552
  console.log(` Agent Card: ${baseUrl}.well-known/agent-card.json`);
@@ -1,9 +1,38 @@
1
1
  import chalk from "chalk";
2
2
  import { loadConfig, saveConfig, DEFAULT_CONFIG } from "../core/engine.js";
3
3
  import { success, error, heading } from "../utils/style.js";
4
+ import { validateQdrantUrl } from "../utils/validate.js";
4
5
 
5
6
  const ALLOWED_KEYS = Object.keys(DEFAULT_CONFIG);
6
7
 
8
+ // キーごとのバリデーター
9
+ const CONFIG_VALIDATORS = {
10
+ qdrant_url: (value) => {
11
+ try {
12
+ const url = new URL(value);
13
+ if (!["http:", "https:"].includes(url.protocol)) {
14
+ throw new Error("http://またはhttps://のみ使用できます");
15
+ }
16
+ } catch (e) {
17
+ throw new Error(`qdrant_urlが無効です: ${e.message}`, { cause: e });
18
+ }
19
+ },
20
+ data_dir: (value) => {
21
+ const { resolve } = require("path");
22
+ const abs = resolve(value);
23
+ // 危険なシステムパスを拒否
24
+ const dangerousPaths = ["/etc", "/usr", "/bin", "/sbin", "/sys", "/proc", "/dev"];
25
+ if (dangerousPaths.some((p) => abs.startsWith(p))) {
26
+ throw new Error(`data_dirにシステムパスは指定できません: ${abs}`);
27
+ }
28
+ },
29
+ collection_name: (value) => {
30
+ if (!/^[a-zA-Z0-9_-]+$/.test(value) || value.length > 256) {
31
+ throw new Error("collection_nameは英数字、ハイフン、アンダースコアのみ使用できます");
32
+ }
33
+ },
34
+ };
35
+
7
36
  export function registerConfig(program) {
8
37
  const cmd = program.command("config").description("Manage shabti configuration");
9
38
 
@@ -39,10 +68,29 @@ export function registerConfig(program) {
39
68
  return;
40
69
  }
41
70
 
71
+ if (CONFIG_VALIDATORS[key]) {
72
+ try {
73
+ CONFIG_VALIDATORS[key](value);
74
+ } catch (e) {
75
+ error(e.message);
76
+ return;
77
+ }
78
+ }
79
+
42
80
  const config = loadConfig();
43
- config[key] = value;
81
+ let validatedValue = value;
82
+ if (key === "qdrant_url") {
83
+ try {
84
+ validatedValue = validateQdrantUrl(value);
85
+ } catch (err) {
86
+ error(err.message);
87
+ process.exitCode = 1;
88
+ return;
89
+ }
90
+ }
91
+ config[key] = validatedValue;
44
92
  saveConfig(config);
45
- success(`${key} = ${value}`);
93
+ success(`${key} = ${validatedValue}`);
46
94
  });
47
95
 
48
96
  cmd
@@ -1,4 +1,5 @@
1
1
  import { writeFileSync } from "fs";
2
+ import { resolve } from "path";
2
3
  import { createEngine } from "../core/engine.js";
3
4
  import { success, error } from "../utils/style.js";
4
5
  import { parsePositiveInt } from "../utils/validate.js";
@@ -28,8 +29,9 @@ export function registerExport(program) {
28
29
  const output = lines.join("\n");
29
30
 
30
31
  if (opts.output) {
31
- writeFileSync(opts.output, output + (lines.length ? "\n" : ""), "utf8");
32
- success(`Exported ${entries.length} entries to ${opts.output}`);
32
+ const absOutput = resolve(opts.output);
33
+ writeFileSync(absOutput, output + (lines.length ? "\n" : ""), "utf8");
34
+ success(`Exported ${entries.length} entries to ${absOutput}`);
33
35
  } else {
34
36
  if (output) console.log(output);
35
37
  // Print summary to stderr so it doesn't pollute piped output
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, existsSync } from "fs";
2
+ import { resolve } from "path";
2
3
  import { createEngine } from "../core/engine.js";
3
4
  import { success, error, info, warn } from "../utils/style.js";
4
5
 
@@ -11,12 +12,13 @@ export function registerImport(program) {
11
12
  .option("--dry-run", "Parse and validate without storing")
12
13
  .action(async (file, opts) => {
13
14
  try {
14
- if (!existsSync(file)) {
15
- error(`File not found: ${file}`);
15
+ const absFile = resolve(file);
16
+ if (!existsSync(absFile)) {
17
+ error(`File not found: ${absFile}`);
16
18
  process.exitCode = 1;
17
19
  return;
18
20
  }
19
- const raw = readFileSync(file, "utf8");
21
+ const raw = readFileSync(absFile, "utf8");
20
22
  const lines = raw
21
23
  .split("\n")
22
24
  .map((l) => l.trim())
@@ -28,6 +28,12 @@ export function registerSnapshot(program) {
28
28
  .argument("<id>", "Snapshot ID to restore")
29
29
  .action(async (id) => {
30
30
  try {
31
+ // UUIDフォーマット検証
32
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
33
+ if (!UUID_REGEX.test(id)) {
34
+ error(`無効なスナップショットID: ${id}(UUID形式で指定してください)`);
35
+ return;
36
+ }
31
37
  const engine = createEngine();
32
38
  engine.snapshotRestore(id);
33
39
  success(`Restored from snapshot: ${id}`);
@@ -19,8 +19,6 @@ export function registerStatus(program) {
19
19
  console.log();
20
20
  console.log(` ${chalk.cyan("Entries:")} ${status.entryCount}`);
21
21
  console.log(` ${chalk.cyan("Tier:")} ${status.tier}`);
22
- console.log(` ${chalk.cyan("Qdrant URL:")} ${status.qdrantUrl}`);
23
- console.log(` ${chalk.cyan("Data Dir:")} ${status.dataDir}`);
24
22
  console.log(` ${chalk.cyan("Model:")} ${status.modelId}`);
25
23
  console.log();
26
24
  }
@@ -1,7 +1,8 @@
1
- import { readFileSync, existsSync, mkdirSync, writeFileSync } from "fs";
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync } from "fs";
2
2
  import { createRequire } from "module";
3
3
  import { homedir } from "os";
4
4
  import { join } from "path";
5
+ import { validateQdrantUrl } from "../utils/validate.js";
5
6
 
6
7
  const require = createRequire(import.meta.url);
7
8
 
@@ -17,7 +18,9 @@ const DEFAULT_CONFIG = {
17
18
  /** Env var overrides (highest priority). */
18
19
  function envOverrides() {
19
20
  const o = {};
20
- if (process.env.SHABTI_QDRANT_URL) o.qdrant_url = process.env.SHABTI_QDRANT_URL;
21
+ if (process.env.SHABTI_QDRANT_URL) {
22
+ o.qdrant_url = validateQdrantUrl(process.env.SHABTI_QDRANT_URL);
23
+ }
21
24
  return o;
22
25
  }
23
26
 
@@ -25,7 +28,11 @@ export function loadConfig() {
25
28
  let config = DEFAULT_CONFIG;
26
29
  if (existsSync(CONFIG_PATH)) {
27
30
  try {
28
- config = { ...config, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) };
31
+ const saved = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
32
+ if (saved.qdrant_url !== undefined) {
33
+ saved.qdrant_url = validateQdrantUrl(saved.qdrant_url);
34
+ }
35
+ config = { ...config, ...saved };
29
36
  } catch (_) {
30
37
  // keep defaults
31
38
  }
@@ -34,13 +41,15 @@ export function loadConfig() {
34
41
  }
35
42
 
36
43
  export function saveConfig(config) {
37
- mkdirSync(CONFIG_DIR, { recursive: true });
38
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
44
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
45
+ const tmpPath = CONFIG_PATH + ".tmp";
46
+ writeFileSync(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600 });
47
+ renameSync(tmpPath, CONFIG_PATH);
39
48
  }
40
49
 
41
50
  export function createEngine(configOverrides = {}) {
42
51
  const config = { ...loadConfig(), ...configOverrides };
43
- mkdirSync(config.data_dir, { recursive: true });
52
+ mkdirSync(config.data_dir, { recursive: true, mode: 0o700 });
44
53
 
45
54
  const { ShabtiEngine } = require("../../native.cjs");
46
55
 
@@ -6,6 +6,10 @@ import { error } from "../utils/style.js";
6
6
  const DEFAULT_SYSTEM_PROMPT =
7
7
  "You are Shabti, a helpful assistant accessed via CLI. Be concise and direct.";
8
8
 
9
+ // 既知の有効なOpenAIモデル名のパターン
10
+ const ALLOWED_MODEL_PATTERN = /^(gpt-[34][- .\w]+|o[12][\w-]*)$/;
11
+ const MAX_HISTORY_TURNS = 50; // 最大50ターン
12
+
9
13
  export class ChatSession {
10
14
  #client;
11
15
  #messages;
@@ -49,9 +53,22 @@ export class ChatSession {
49
53
  }
50
54
 
51
55
  setModel(model) {
56
+ if (!ALLOWED_MODEL_PATTERN.test(model)) {
57
+ throw new Error(`無効なモデル名: ${model}`);
58
+ }
52
59
  this.#model = model;
53
60
  }
54
61
 
62
+ #trimHistory() {
63
+ // システムメッセージは保持、ユーザー/アシスタントのペアを最新N件に制限
64
+ const systemMessages = this.#messages.filter((m) => m.role === "system");
65
+ const conversationMessages = this.#messages.filter((m) => m.role !== "system");
66
+
67
+ if (conversationMessages.length > MAX_HISTORY_TURNS * 2) {
68
+ this.#messages = [...systemMessages, ...conversationMessages.slice(-MAX_HISTORY_TURNS * 2)];
69
+ }
70
+ }
71
+
55
72
  getModel() {
56
73
  return this.#model;
57
74
  }
@@ -84,6 +101,7 @@ export class ChatSession {
84
101
  }
85
102
 
86
103
  this.#messages.push({ role: "user", content: trimmed });
104
+ this.#trimHistory();
87
105
 
88
106
  try {
89
107
  process.stdout.write(chalk.cyan("shabti: "));
package/src/mcp/server.js CHANGED
@@ -226,7 +226,6 @@ async function handleToolsCall(id, params) {
226
226
  entry_count: status.entryCount,
227
227
  tier: status.tier,
228
228
  model_id: status.modelId,
229
- qdrant_url: status.qdrantUrl,
230
229
  },
231
230
  null,
232
231
  2,
@@ -280,8 +279,10 @@ async function handleToolsCall(id, params) {
280
279
  const queryObj = { text: normalizeText(query), limit };
281
280
  if (args.namespace) queryObj.namespace = args.namespace;
282
281
  const results = await eng.executeQuery(queryObj);
282
+ const formatMemoryEntry = (entry) =>
283
+ `[MEMORY_START id="${entry.id}"]\n${entry.content}\n[MEMORY_END]`;
283
284
  const formatted = results.map((r) => ({
284
- content: r.content,
285
+ content: formatMemoryEntry(r),
285
286
  score: r.score,
286
287
  id: r.id,
287
288
  namespace: r.namespace,
@@ -503,12 +504,17 @@ function handleResourcesRead(id, params) {
503
504
 
504
505
  if (uri === "shabti://config") {
505
506
  const config = loadConfig();
507
+ // 機密フィールドを除いた安全な設定情報のみ返す
508
+ const safeConfig = {
509
+ collection_name: config.collection_name,
510
+ // qdrant_url, data_dir は公開しない
511
+ };
506
512
  return respond(id, {
507
513
  contents: [
508
514
  {
509
515
  uri,
510
516
  mimeType: "application/json",
511
- text: JSON.stringify(config),
517
+ text: JSON.stringify(safeConfig),
512
518
  },
513
519
  ],
514
520
  });
@@ -549,7 +555,10 @@ async function handleRequest(line) {
549
555
  const rl = createInterface({ input: process.stdin, terminal: false });
550
556
  rl.on("line", (line) => {
551
557
  const trimmed = line.trim();
552
- if (trimmed) handleRequest(trimmed);
558
+ if (trimmed && trimmed.length <= 10_000_000) {
559
+ // 10MB上限
560
+ handleRequest(trimmed);
561
+ }
553
562
  });
554
563
 
555
564
  async function shutdown() {
@@ -48,8 +48,12 @@ export function handleSlashCommand(cmd, args, session, rl, engine = null) {
48
48
 
49
49
  case "/model":
50
50
  if (args) {
51
- session.setModel(args);
52
- success(`Model switched to ${chalk.cyan(args)}`);
51
+ try {
52
+ session.setModel(args);
53
+ success(`Model switched to ${chalk.cyan(args)}`);
54
+ } catch (err) {
55
+ error(err.message);
56
+ }
53
57
  } else {
54
58
  info(`Current model: ${chalk.cyan(session.getModel())}`);
55
59
  }
@@ -132,7 +136,8 @@ async function handleRecall(query, engine) {
132
136
  console.log(chalk.dim(" No memories found."));
133
137
  } else {
134
138
  for (const r of results) {
135
- console.log(` ${chalk.yellow(r.score.toFixed(4))} ${r.content}`);
139
+ const formatted = `[MEMORY_START id="${r.id}"]\n${r.content}\n[MEMORY_END]`;
140
+ console.log(` ${chalk.yellow(r.score.toFixed(4))} ${formatted}`);
136
141
  }
137
142
  }
138
143
  } catch (err) {
@@ -52,3 +52,20 @@ export function parsePort(value, name) {
52
52
  }
53
53
  return n;
54
54
  }
55
+
56
+ /**
57
+ * qdrant_urlのSSRF防止バリデーション
58
+ * http/httpsスキームのみ許可し、非ローカルホストの場合は警告を出す
59
+ */
60
+ export function validateQdrantUrl(raw) {
61
+ let parsed;
62
+ try {
63
+ parsed = new URL(raw);
64
+ } catch {
65
+ throw new Error(`無効なqdrant_url: ${raw}`);
66
+ }
67
+ if (!["http:", "https:"].includes(parsed.protocol)) {
68
+ throw new Error(`qdrant_urlにはhttp://またはhttps://のみ使用できます: ${raw}`);
69
+ }
70
+ return raw;
71
+ }