@anhlp/alp-core 0.0.0-beta.2 → 0.0.1-beta.3

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.
@@ -1,3 +1,6 @@
1
+ /** Strict payload contract for opt-in project-data writes. Instructions carry prompt authority;
2
+ * executable configuration and references to it have no place in this policy. */
3
+ export function assertProjectDataContent(kind: any, content: any): void;
1
4
  /**
2
5
  * One entry: the version that applies (the project's, else the library's, else the
3
6
  * built-in), or one scope's version with `scope`. `revision` goes back with a save.
@@ -52,7 +55,7 @@ export function usersOf(kind: any, name: any, { root, library, templates }?: {})
52
55
  * that scope (null to create); a different current revision refuses the save.
53
56
  * @returns {Promise<{ revision: string | null }>}
54
57
  */
55
- export function saveEntry(kind: any, name: any, content: any, { scope, root, library, templates, revision }?: {}): Promise<{
58
+ export function saveEntry(kind: any, name: any, content: any, { scope, root, library, templates, revision, policy }?: {}): Promise<{
56
59
  revision: string | null;
57
60
  }>;
58
61
  /**
@@ -60,16 +63,16 @@ export function saveEntry(kind: any, name: any, content: any, { scope, root, lib
60
63
  * entry others refer to stays, and the error lists them.
61
64
  * @returns {Promise<{ removed: boolean, now?: 'builtin' | 'library' }>}
62
65
  */
63
- export function deleteEntry(kind: any, name: any, { scope, root, library, templates, revision }?: {}): Promise<{
66
+ export function deleteEntry(kind: any, name: any, { scope, root, library, templates, revision, policy }?: {}): Promise<{
64
67
  removed: boolean;
65
68
  now?: "builtin" | "library";
66
69
  }>;
67
70
  /** Copies the entry that applies under `from` to a new entry `to` in a scope; built-ins are copied this way. */
68
- export function duplicateEntry(kind: any, from: any, to: any, { scope, root, library, templates }?: {}): Promise<{
71
+ export function duplicateEntry(kind: any, from: any, to: any, { scope, root, library, templates, policy }?: {}): Promise<{
69
72
  revision: string | null;
70
73
  }>;
71
74
  /** Renames an entry within a scope; refused while others refer to it by its old name. */
72
- export function renameEntry(kind: any, from: any, to: any, { scope, root, library, templates }?: {}): Promise<{
75
+ export function renameEntry(kind: any, from: any, to: any, { scope, root, library, templates, policy }?: {}): Promise<{
73
76
  revision: string | null;
74
77
  }>;
75
78
  /**
@@ -1,5 +1,5 @@
1
1
  // packages/core/src/library-edit.js
2
- import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import path from "node:path";
5
5
  import { moveFile } from "./atomic.js";
@@ -39,6 +39,42 @@ function base(scope, { root, library }, kind) {
39
39
  return fail("INVALID_SCOPE", `Scope must be library or project, not '${scope}'`);
40
40
  }
41
41
  var revisionOf = (content) => content === void 0 ? null : createHash("sha256").update(JSON.stringify(content)).digest("hex").slice(0, 16);
42
+ function assertProjectDataContent(kind, content) {
43
+ const deny = () => fail("UNSAFE_LIBRARY_WRITE", "Project-data writes allow only instruction entries and non-executable agent configuration");
44
+ if (!object(content) || !["agents", "skills"].includes(kind)) deny();
45
+ const keys = kind === "skills" ? ["body"] : ["instructions", "config"];
46
+ if (Object.keys(content).some((key) => !keys.includes(key))) deny();
47
+ const text = kind === "skills" ? content.body : content.instructions;
48
+ if (typeof text !== "string" || !text.trim()) deny();
49
+ if (content.config !== void 0) {
50
+ const config = content.config;
51
+ if (!object(config)) deny();
52
+ const allowed = ["description", "model", "thinking", "context", "autocompact"];
53
+ if (Object.keys(config).some((key) => !allowed.includes(key))) deny();
54
+ for (const key of ["description", "thinking"]) if (config[key] !== void 0 && typeof config[key] !== "string") deny();
55
+ if (config.model !== void 0 && (typeof config.model !== "string" || !/^(codex|claude):[^\s/\\:]{1,240}$/.test(config.model))) deny();
56
+ for (const key of ["context", "autocompact"]) if (config[key] !== void 0 && !(typeof config[key] === "string" || typeof config[key] === "number" && Number.isFinite(config[key]))) deny();
57
+ }
58
+ }
59
+ async function projectDataEntry(kind, name, { scope, root, library, templates }) {
60
+ if (scope !== "project" || !root || !["agents", "skills"].includes(kind)) fail("UNSAFE_LIBRARY_WRITE", "Project-data writes require project scope and agents or skills");
61
+ nameOf(name);
62
+ const directory = base(scope, { root, library }, kind);
63
+ await safeEntryPaths(directory, kind, name);
64
+ const found = await layers(kind, name, { root, library, templates });
65
+ if (found.library !== void 0 || found.builtin !== void 0) fail("UNSAFE_LIBRARY_WRITE", "Project-data writes cannot shadow or reveal a library or built-in entry");
66
+ if (found.project !== void 0) assertProjectDataContent(kind, found.project);
67
+ const owned = path.join(directory, kind, name);
68
+ const files = await readdir(owned).catch((error) => {
69
+ if (error.code === "ENOENT") return [];
70
+ throw error;
71
+ });
72
+ const allowed = new Set(FILES[kind](name).map(([file]) => path.basename(file)));
73
+ for (const file of files) {
74
+ const info = await lstat(path.join(owned, file));
75
+ if (!allowed.has(file) || !info.isFile() || info.isSymbolicLink() || info.nlink !== 1) fail("UNSAFE_LIBRARY_WRITE", "Project-data entries cannot contain sidecars, links or executable assets");
76
+ }
77
+ }
42
78
  async function safeEntryPaths(directory, kind, name) {
43
79
  for (const [file] of FILES[kind](name)) {
44
80
  await assertNoSymlinks(directory, path.join(kind, file));
@@ -236,9 +272,13 @@ async function writeAtomic(file, text) {
236
272
  await moveFile(temporary, file);
237
273
  }
238
274
  var exists = (target) => stat(target).then(() => true, () => false);
239
- async function saveEntry(kind, name, content, { scope, root, library, templates, revision } = {}) {
275
+ async function saveEntry(kind, name, content, { scope, root, library, templates, revision, policy } = {}) {
240
276
  kindOf(kind);
241
277
  nameOf(name);
278
+ if (policy === "project-data") {
279
+ assertProjectDataContent(kind, content);
280
+ await projectDataEntry(kind, name, { scope, root, library, templates });
281
+ }
242
282
  const directory = base(scope, { root, library }, kind);
243
283
  await safeEntryPaths(directory, kind, name);
244
284
  await check(kind, name, content, { scope, root, library, templates });
@@ -260,9 +300,10 @@ async function saveEntry(kind, name, content, { scope, root, library, templates,
260
300
  }
261
301
  return { revision: revisionOf(await readLayer(kind, name, directory)) };
262
302
  }
263
- async function deleteEntry(kind, name, { scope, root, library, templates, revision } = {}) {
303
+ async function deleteEntry(kind, name, { scope, root, library, templates, revision, policy } = {}) {
264
304
  kindOf(kind);
265
305
  nameOf(name);
306
+ if (policy === "project-data") await projectDataEntry(kind, name, { scope, root, library, templates });
266
307
  const directory = base(scope, { root, library }, kind);
267
308
  const current = await readLayer(kind, name, directory);
268
309
  if (current === void 0) fail("NOT_FOUND", `No ${kind} entry '${name}' in the ${scope}`);
@@ -278,14 +319,25 @@ async function deleteEntry(kind, name, { scope, root, library, templates, revisi
278
319
  const now = scope === "project" && found.library !== void 0 ? "library" : "builtin";
279
320
  return { removed: true, ...fallback !== void 0 ? { now } : {} };
280
321
  }
281
- async function duplicateEntry(kind, from, to, { scope, root, library, templates } = {}) {
322
+ async function duplicateEntry(kind, from, to, { scope, root, library, templates, policy } = {}) {
323
+ kindOf(kind);
324
+ nameOf(from);
325
+ nameOf(to);
326
+ if (policy === "project-data") {
327
+ await projectDataEntry(kind, from, { scope, root, library, templates });
328
+ await projectDataEntry(kind, to, { scope, root, library, templates });
329
+ }
282
330
  const { content } = await getEntry(kind, from, { root, library, templates });
283
- return saveEntry(kind, to, structuredClone(content), { scope, root, library, templates, revision: null });
331
+ return saveEntry(kind, to, structuredClone(content), { scope, root, library, templates, revision: null, policy });
284
332
  }
285
- async function renameEntry(kind, from, to, { scope, root, library, templates } = {}) {
333
+ async function renameEntry(kind, from, to, { scope, root, library, templates, policy } = {}) {
286
334
  kindOf(kind);
287
335
  nameOf(from);
288
336
  nameOf(to);
337
+ if (policy === "project-data") {
338
+ await projectDataEntry(kind, from, { scope, root, library, templates });
339
+ await projectDataEntry(kind, to, { scope, root, library, templates });
340
+ }
289
341
  const directory = base(scope, { root, library }, kind);
290
342
  const content = await readLayer(kind, from, directory);
291
343
  if (content === void 0) fail("NOT_FOUND", `No ${kind} entry '${from}' in the ${scope}`);
@@ -303,6 +355,7 @@ async function renameEntry(kind, from, to, { scope, root, library, templates } =
303
355
  }
304
356
  export {
305
357
  EDIT_KINDS,
358
+ assertProjectDataContent,
306
359
  deleteEntry,
307
360
  duplicateEntry,
308
361
  getEntry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anhlp/alp-core",
3
- "version": "0.0.0-beta.2",
3
+ "version": "0.0.1-beta.3",
4
4
  "type": "module",
5
5
  "description": "ALP core module",
6
6
  "files": [
@@ -284,7 +284,7 @@
284
284
  "node": ">=22.12"
285
285
  },
286
286
  "dependencies": {
287
- "@anhlp/alp-protocol": "0.0.0-beta.2"
287
+ "@anhlp/alp-protocol": "0.0.1-beta.3"
288
288
  },
289
289
  "scripts": {
290
290
  "build": "node ../../scripts/build-workspace.mjs core",
@@ -1,4 +1,4 @@
1
- import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
1
+ import { lstat, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import path from 'node:path';
4
4
  import { moveFile } from './atomic.js';
@@ -60,6 +60,46 @@ function base(scope, { root, library }, kind) {
60
60
  }
61
61
  const revisionOf = content => content === undefined ? null : createHash('sha256').update(JSON.stringify(content)).digest('hex').slice(0, 16);
62
62
 
63
+ /** Strict payload contract for opt-in project-data writes. Instructions carry prompt authority;
64
+ * executable configuration and references to it have no place in this policy. */
65
+ export function assertProjectDataContent(kind, content) {
66
+ const deny = () => fail('UNSAFE_LIBRARY_WRITE', 'Project-data writes allow only instruction entries and non-executable agent configuration');
67
+ if (!object(content) || !['agents', 'skills'].includes(kind)) deny();
68
+ const keys = kind === 'skills' ? ['body'] : ['instructions', 'config'];
69
+ if (Object.keys(content).some(key => !keys.includes(key))) deny();
70
+ const text = kind === 'skills' ? content.body : content.instructions;
71
+ if (typeof text !== 'string' || !text.trim()) deny();
72
+ if (content.config !== undefined) {
73
+ const config = content.config;
74
+ if (!object(config)) deny();
75
+ const allowed = ['description', 'model', 'thinking', 'context', 'autocompact'];
76
+ if (Object.keys(config).some(key => !allowed.includes(key))) deny();
77
+ for (const key of ['description', 'thinking']) if (config[key] !== undefined && typeof config[key] !== 'string') deny();
78
+ if (config.model !== undefined && (typeof config.model !== 'string' || !/^(codex|claude):[^\s/\\:]{1,240}$/.test(config.model))) deny();
79
+ for (const key of ['context', 'autocompact']) if (config[key] !== undefined && !(typeof config[key] === 'string' || typeof config[key] === 'number' && Number.isFinite(config[key]))) deny();
80
+ }
81
+ }
82
+
83
+ /** Authoritative mutation preflight. Reject sidecars and lower-layer collisions rather than
84
+ * moving executable assets or revealing a different host configuration. Like safeEntryPaths,
85
+ * this protects static repository input, not a concurrently malicious same-user process. */
86
+ async function projectDataEntry(kind, name, { scope, root, library, templates }) {
87
+ if (scope !== 'project' || !root || !['agents', 'skills'].includes(kind)) fail('UNSAFE_LIBRARY_WRITE', 'Project-data writes require project scope and agents or skills');
88
+ nameOf(name);
89
+ const directory = base(scope, { root, library }, kind);
90
+ await safeEntryPaths(directory, kind, name);
91
+ const found = await layers(kind, name, { root, library, templates });
92
+ if (found.library !== undefined || found.builtin !== undefined) fail('UNSAFE_LIBRARY_WRITE', 'Project-data writes cannot shadow or reveal a library or built-in entry');
93
+ if (found.project !== undefined) assertProjectDataContent(kind, found.project);
94
+ const owned = path.join(directory, kind, name);
95
+ const files = await readdir(owned).catch(error => { if (error.code === 'ENOENT') return []; throw error; });
96
+ const allowed = new Set(FILES[kind](name).map(([file]) => path.basename(file)));
97
+ for (const file of files) {
98
+ const info = await lstat(path.join(owned, file));
99
+ if (!allowed.has(file) || !info.isFile() || info.isSymbolicLink() || info.nlink !== 1) fail('UNSAFE_LIBRARY_WRITE', 'Project-data entries cannot contain sidecars, links or executable assets');
100
+ }
101
+ }
102
+
63
103
  /** Reject repository-controlled links before any entry read or mutation. This is
64
104
  * a preflight for static repository input, not a sandbox against same-user races. */
65
105
  async function safeEntryPaths(directory, kind, name) {
@@ -274,8 +314,12 @@ const exists = target => stat(target).then(() => true, () => false);
274
314
  * that scope (null to create); a different current revision refuses the save.
275
315
  * @returns {Promise<{ revision: string | null }>}
276
316
  */
277
- export async function saveEntry(kind, name, content, { scope, root, library, templates, revision } = {}) {
317
+ export async function saveEntry(kind, name, content, { scope, root, library, templates, revision, policy } = {}) {
278
318
  kindOf(kind); nameOf(name);
319
+ if (policy === 'project-data') {
320
+ assertProjectDataContent(kind, content);
321
+ await projectDataEntry(kind, name, { scope, root, library, templates });
322
+ }
279
323
  const directory = base(scope, { root, library }, kind);
280
324
  await safeEntryPaths(directory, kind, name);
281
325
  await check(kind, name, content, { scope, root, library, templates });
@@ -300,8 +344,9 @@ export async function saveEntry(kind, name, content, { scope, root, library, tem
300
344
  * entry others refer to stays, and the error lists them.
301
345
  * @returns {Promise<{ removed: boolean, now?: 'builtin' | 'library' }>}
302
346
  */
303
- export async function deleteEntry(kind, name, { scope, root, library, templates, revision } = {}) {
347
+ export async function deleteEntry(kind, name, { scope, root, library, templates, revision, policy } = {}) {
304
348
  kindOf(kind); nameOf(name);
349
+ if (policy === 'project-data') await projectDataEntry(kind, name, { scope, root, library, templates });
305
350
  const directory = base(scope, { root, library }, kind);
306
351
  const current = await readLayer(kind, name, directory);
307
352
  if (current === undefined) fail('NOT_FOUND', `No ${kind} entry '${name}' in the ${scope}`);
@@ -320,14 +365,23 @@ export async function deleteEntry(kind, name, { scope, root, library, templates,
320
365
  }
321
366
 
322
367
  /** Copies the entry that applies under `from` to a new entry `to` in a scope; built-ins are copied this way. */
323
- export async function duplicateEntry(kind, from, to, { scope, root, library, templates } = {}) {
368
+ export async function duplicateEntry(kind, from, to, { scope, root, library, templates, policy } = {}) {
369
+ kindOf(kind); nameOf(from); nameOf(to);
370
+ if (policy === 'project-data') {
371
+ await projectDataEntry(kind, from, { scope, root, library, templates });
372
+ await projectDataEntry(kind, to, { scope, root, library, templates });
373
+ }
324
374
  const { content } = await getEntry(kind, from, { root, library, templates });
325
- return saveEntry(kind, to, structuredClone(content), { scope, root, library, templates, revision: null });
375
+ return saveEntry(kind, to, structuredClone(content), { scope, root, library, templates, revision: null, policy });
326
376
  }
327
377
 
328
378
  /** Renames an entry within a scope; refused while others refer to it by its old name. */
329
- export async function renameEntry(kind, from, to, { scope, root, library, templates } = {}) {
379
+ export async function renameEntry(kind, from, to, { scope, root, library, templates, policy } = {}) {
330
380
  kindOf(kind); nameOf(from); nameOf(to);
381
+ if (policy === 'project-data') {
382
+ await projectDataEntry(kind, from, { scope, root, library, templates });
383
+ await projectDataEntry(kind, to, { scope, root, library, templates });
384
+ }
331
385
  const directory = base(scope, { root, library }, kind);
332
386
  const content = await readLayer(kind, from, directory);
333
387
  if (content === undefined) fail('NOT_FOUND', `No ${kind} entry '${from}' in the ${scope}`);
@@ -343,4 +397,3 @@ export async function renameEntry(kind, from, to, { scope, root, library, templa
343
397
  }
344
398
  return { revision: revisionOf(await readLayer(kind, to, directory)) };
345
399
  }
346
-