@kuznai/inception-engine 0.23.0 → 0.24.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/README.md CHANGED
@@ -171,6 +171,8 @@ Create an `inception.json` file at the root of your skills directory:
171
171
  }
172
172
  ```
173
173
 
174
+ `files` and `configs` are intentionally narrower than the raw target-template syntax might suggest. Arbitrary project-local targets under `{repo}` and `{workspace}` are allowed, but user-profile targets under `{home}`, `{appdata}`, and `{xdg_config}` must resolve to documented agent-owned global surfaces such as `~/.claude/settings.json` or `~/.codex/AGENTS.md`. Manifests cannot target inception-engine's own `~/.inception-engine/` state directory.
175
+
174
176
  Each **skill** entry has:
175
177
 
176
178
  - **name** - Unique identifier using letters, digits, dots, underscores, or hyphens; must not start with a dot
@@ -542,9 +544,11 @@ inception-engine maintains a centralized deployment registry at `~/.inception-en
542
544
 
543
545
  - **Strong binding**: Each registry entry binds a specific target path to its skill, agent, and action kind. For `skill-dir` and `file-write`, ownership checks also require the recorded `source` to match before an existing target is treated as managed. For `config-patch` and `frontmatter-emit`, overwrite protection is keyed by target path, kind, skill, and agent; the stored `patch` and `undoPatch` are used for patch-level revert bookkeeping rather than deploy-time identity checks.
544
546
 
547
+ - **Reserved state**: Manifest-driven deploy and revert actions are not allowed to touch `~/.inception-engine/`, and the registry loader refuses symlinked registry files or directories.
548
+
545
549
  - **Atomic redeploy**: When overwriting an existing managed `skill-dir` target, the engine renames the old target to a backup, creates the new deployment, and only removes the backup on success. If the new deployment fails, the backup is restored. `file-write` and `config-patch` deployments write directly to the target without this backup/rollback model.
546
550
 
547
- - **Cross-platform**: The registry uses the same resolved home directory as the rest of the tool, including sudo scenarios on POSIX and elevated PowerShell on Windows.
551
+ - **Cross-platform**: The registry uses the same resolved home directory as the rest of the tool, including sudo scenarios on POSIX and elevated PowerShell on Windows. On POSIX, inception-engine keeps the state directory at `0700` and the registry file at `0600`.
548
552
 
549
553
  ## Running with Privilege Escalation
550
554
 
@@ -9,7 +9,7 @@ import { compileAdapterActions } from "./adapters/index.js";
9
9
  import { applyTomlMcpPatch } from "./adapters/toml.js";
10
10
  import { planCapabilityForDeploy } from "./capabilities.js";
11
11
  import { applyMergePatch, computeUndoPatch, isPlainObject, } from "./merge-patch.js";
12
- import { lookupDeployment, registerDeployment, verifyDeployment, } from "./ownership.js";
12
+ import { lookupDeployment, registryDirPath, registerDeployment, verifyDeployment, } from "./ownership.js";
13
13
  import { getDeployMethod, resolveAgentSkillPath } from "./resolve.js";
14
14
  import { resolveTargetTemplate } from "./runtime-paths.js";
15
15
  import { sourceAccessError, validateSkillDefinitionFile, validateSourceFile, validateSourcePath, } from "./validation.js";
@@ -153,6 +153,62 @@ function detectAmbiguities(detectedAgents, manifest) {
153
153
  }
154
154
  return warnings;
155
155
  }
156
+ function normalizeTemplatePath(template) {
157
+ return template.replaceAll("\\", "/");
158
+ }
159
+ function isRepoScopedTemplate(template) {
160
+ return template.startsWith("{repo}") || template.startsWith("{workspace}");
161
+ }
162
+ function normalizePathForComparison(candidate) {
163
+ const normalized = path.normalize(candidate);
164
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
165
+ }
166
+ function isSameOrDescendantPath(candidate, root) {
167
+ const normalizedCandidate = normalizePathForComparison(candidate);
168
+ const normalizedRoot = normalizePathForComparison(root);
169
+ return (normalizedCandidate === normalizedRoot ||
170
+ normalizedCandidate.startsWith(normalizedRoot + path.sep));
171
+ }
172
+ function collectApprovedGlobalSurfaceTemplates(agentId) {
173
+ const agent = AGENT_REGISTRY_BY_ID[agentId];
174
+ if (!agent)
175
+ return new Set();
176
+ const supports = [
177
+ agent.mcpSupport,
178
+ agent.agentRulesSupport,
179
+ agent.permissionsSupport,
180
+ agent.hooksSupport,
181
+ agent.executionConfigSupport,
182
+ ];
183
+ const approved = new Set();
184
+ for (const support of supports) {
185
+ if (!support || support.status !== "supported")
186
+ continue;
187
+ approved.add(normalizeTemplatePath(support.path.posix.join("/")));
188
+ approved.add(normalizeTemplatePath(support.path.windows.join("/")));
189
+ }
190
+ return approved;
191
+ }
192
+ function assertApprovedManagedTargetTemplate(template, targetAgents, kind) {
193
+ if (isRepoScopedTemplate(template))
194
+ return;
195
+ const normalizedTemplate = normalizeTemplatePath(template);
196
+ for (const agentId of targetAgents) {
197
+ const approvedTemplates = collectApprovedGlobalSurfaceTemplates(agentId);
198
+ if (approvedTemplates.has(normalizedTemplate)) {
199
+ return;
200
+ }
201
+ }
202
+ throw new UserError("DEPLOY_FAILED", `${kind} target "${template}" is not an approved managed surface. ` +
203
+ `Use {repo}/... or {workspace}/... for arbitrary project-local paths, ` +
204
+ `or target a documented agent-owned global config surface.`);
205
+ }
206
+ function assertTargetOutsideReservedEngineState(targetPath, home) {
207
+ const reservedDir = registryDirPath(home);
208
+ if (isSameOrDescendantPath(targetPath, reservedDir)) {
209
+ throw new UserError("DEPLOY_FAILED", `Target "${targetPath}" is inside inception-engine state directory "${reservedDir}" and cannot be managed by manifests`);
210
+ }
211
+ }
156
212
  function checkAntigravityPathCollisions(manifest) {
157
213
  const warnings = [];
158
214
  const defNames = new Set((manifest.agentDefinitions ?? [])
@@ -223,6 +279,7 @@ async function planFileWriteActions(manifest, sourceDir, resolvedSourceDir, real
223
279
  const targetAgents = fileEntry.agents.filter((agentId) => detectedAgents.includes(agentId));
224
280
  if (targetAgents.length === 0)
225
281
  return;
282
+ assertApprovedManagedTargetTemplate(fileEntry.target, targetAgents, "files");
226
283
  const source = path.resolve(sourceDir, fileEntry.path);
227
284
  await validateSourcePath(source, fileEntry.path, resolvedSourceDir, realRoot);
228
285
  await validateSourceFile(source, fileEntry.path);
@@ -245,6 +302,10 @@ async function planFileWriteActions(manifest, sourceDir, resolvedSourceDir, real
245
302
  function planConfigPatchActions(manifest, detectedAgents, home, repo, workspace) {
246
303
  const actions = [];
247
304
  for (const configEntry of manifest.configs ?? []) {
305
+ const targetAgents = configEntry.agents.filter((agentId) => detectedAgents.includes(agentId));
306
+ if (targetAgents.length === 0)
307
+ continue;
308
+ assertApprovedManagedTargetTemplate(configEntry.target, targetAgents, "configs");
248
309
  for (const agentId of configEntry.agents) {
249
310
  if (!detectedAgents.includes(agentId))
250
311
  continue;
@@ -289,6 +350,9 @@ export async function planDeploy(manifest, sourceDir, detectedAgents, home, repo
289
350
  ...detectCollisions(actions),
290
351
  ...adapterResult.warnings,
291
352
  ];
353
+ for (const action of actions) {
354
+ assertTargetOutsideReservedEngineState(action.target, home);
355
+ }
292
356
  return { actions, warnings };
293
357
  }
294
358
  export async function executeDeploy(actions, dryRun, verbose, home, deps = {}) {
@@ -21,6 +21,7 @@ export type VerifyExpected = {
21
21
  agent: AgentId;
22
22
  };
23
23
  export declare function registryPath(home: string): string;
24
+ export declare function registryDirPath(home: string): string;
24
25
  export declare const defaultRegistryPersistence: RegistryPersistence;
25
26
  export type RegisterEntry = Omit<SkillDirRegistryEntry, "deployed"> | Omit<FileWriteRegistryEntry, "deployed"> | Omit<ConfigPatchRegistryEntry, "deployed"> | Omit<FrontmatterEmitRegistryEntry, "deployed">;
26
27
  export declare function registerDeployment(home: string, targetPath: string, entry: RegisterEntry, persistence?: RegistryPersistence): Promise<void>;
@@ -1,4 +1,4 @@
1
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { chmod, lstat, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { RegistrySchema, } from "../schemas/registry.js";
4
4
  const REGISTRY_DIR = ".inception-engine";
@@ -6,8 +6,26 @@ const REGISTRY_FILE = "registry.json";
6
6
  export function registryPath(home) {
7
7
  return path.join(home, REGISTRY_DIR, REGISTRY_FILE);
8
8
  }
9
+ export function registryDirPath(home) {
10
+ return path.join(home, REGISTRY_DIR);
11
+ }
12
+ async function assertSafeRegistryStoragePath(targetPath, label) {
13
+ try {
14
+ const stat = await lstat(targetPath);
15
+ if (stat.isSymbolicLink()) {
16
+ throw new Error(`Refusing to use ${label} symlink: ${targetPath}`);
17
+ }
18
+ }
19
+ catch (err) {
20
+ if (err.code === "ENOENT")
21
+ return;
22
+ throw err;
23
+ }
24
+ }
9
25
  async function loadRegistry(home) {
10
26
  try {
27
+ await assertSafeRegistryStoragePath(registryDirPath(home), "registry directory");
28
+ await assertSafeRegistryStoragePath(registryPath(home), "registry file");
11
29
  const content = await readFile(registryPath(home), "utf-8");
12
30
  const parsed = JSON.parse(content);
13
31
  const result = RegistrySchema.safeParse(parsed);
@@ -18,9 +36,12 @@ async function loadRegistry(home) {
18
36
  }
19
37
  }
20
38
  async function saveRegistry(home, registry) {
21
- const dir = path.join(home, REGISTRY_DIR);
39
+ const dir = registryDirPath(home);
40
+ await assertSafeRegistryStoragePath(dir, "registry directory");
22
41
  await mkdir(dir, { recursive: true });
42
+ await setDirectoryPermissions(dir);
23
43
  const filePath = registryPath(home);
44
+ await assertSafeRegistryStoragePath(filePath, "registry file");
24
45
  await writeFile(filePath, `${JSON.stringify(registry, null, 2)}\n`);
25
46
  await setFilePermissions(filePath);
26
47
  }
@@ -29,12 +50,21 @@ export const defaultRegistryPersistence = {
29
50
  save: saveRegistry,
30
51
  };
31
52
  /**
32
- * Ensure the file is not world-writable regardless of umask.
53
+ * Restrict access to the state directory regardless of umask.
54
+ * On Windows, the OS inherits ACLs from the parent directory — no-op is correct.
55
+ */
56
+ async function setDirectoryPermissions(dirPath) {
57
+ if (process.platform !== "win32") {
58
+ await chmod(dirPath, 0o700);
59
+ }
60
+ }
61
+ /**
62
+ * Restrict the registry file to the current user regardless of umask.
33
63
  * On Windows, the OS inherits ACLs from the parent directory — no-op is correct.
34
64
  */
35
65
  async function setFilePermissions(filePath) {
36
66
  if (process.platform !== "win32") {
37
- await chmod(filePath, 0o644);
67
+ await chmod(filePath, 0o600);
38
68
  }
39
69
  }
40
70
  function emptyRegistry() {
@@ -1,11 +1,12 @@
1
1
  import { lstat, readFile, rm, unlink, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
2
3
  import { AGENT_REGISTRY_BY_ID } from "../config/agents.js";
3
4
  import { logger } from "../logger.js";
4
5
  import * as frontmatterAdapter from "./adapters/frontmatter.js";
5
6
  import { compileAgentDefinitionReverts, compileAgentRuleReverts, compileExecutionConfigReverts, compileHookReverts, compileMcpServerReverts, compilePermissionsReverts, } from "./adapters/index.js";
6
7
  import { revertTomlMcpPatch } from "./adapters/toml.js";
7
8
  import { applyUndoPatch } from "./merge-patch.js";
8
- import { lookupDeployment, unregisterDeployment, } from "./ownership.js";
9
+ import { lookupDeployment, registryDirPath, unregisterDeployment, } from "./ownership.js";
9
10
  import { resolveAgentSkillPath } from "./resolve.js";
10
11
  import { resolveTargetTemplate } from "./runtime-paths.js";
11
12
  function buildSkillDirReverts(manifest, home, agentFilter) {
@@ -125,6 +126,54 @@ function lstatOutcome(err) {
125
126
  const msg = err instanceof Error ? err.message : String(err);
126
127
  return { outcome: "fail", error: msg };
127
128
  }
129
+ function normalizePathForComparison(candidate) {
130
+ const normalized = path.normalize(candidate);
131
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
132
+ }
133
+ function isSameOrDescendantPath(candidate, root) {
134
+ const normalizedCandidate = normalizePathForComparison(candidate);
135
+ const normalizedRoot = normalizePathForComparison(root);
136
+ return (normalizedCandidate === normalizedRoot ||
137
+ normalizedCandidate.startsWith(normalizedRoot + path.sep));
138
+ }
139
+ function failReservedStateTarget(action, home) {
140
+ const reservedDir = registryDirPath(home);
141
+ if (!isSameOrDescendantPath(action.target, reservedDir)) {
142
+ return null;
143
+ }
144
+ return {
145
+ outcome: "fail",
146
+ error: `Refusing to modify inception-engine state directory "${reservedDir}" ` +
147
+ `via manifest-managed revert target "${action.target}"`,
148
+ };
149
+ }
150
+ async function preflightManagedSkillDirRevert(action, label, home, deps) {
151
+ const reservedTargetFailure = failReservedStateTarget(action, home);
152
+ if (reservedTargetFailure) {
153
+ if (reservedTargetFailure.outcome === "fail") {
154
+ logger.fail(label, reservedTargetFailure.error);
155
+ }
156
+ return reservedTargetFailure;
157
+ }
158
+ try {
159
+ await lstat(action.target);
160
+ }
161
+ catch (err) {
162
+ const result = lstatOutcome(err);
163
+ if (result.outcome === "skip") {
164
+ logger.skip(label, "(not found, skipping)");
165
+ return result;
166
+ }
167
+ logger.fail(label, result.error);
168
+ return result;
169
+ }
170
+ const entry = await lookupDeployment(home, action.target, deps.registry);
171
+ if (!entry || entry.skill !== action.skill || entry.agent !== action.agent) {
172
+ logger.warn(label, `skipping: ${action.target} is not in the deployment registry — not managed by inception-engine`);
173
+ return { outcome: "skip" };
174
+ }
175
+ return null;
176
+ }
128
177
  export async function executeRevert(actions, dryRun, verbose, home, deps = {}) {
129
178
  const failed = [];
130
179
  const planned = [];
@@ -198,6 +247,13 @@ async function applyFrontmatterRevert(action, frontmatterEntry) {
198
247
  }
199
248
  async function revertFrontmatterEmit(action, dryRun, verbose, home, planned, deps) {
200
249
  const label = `${action.skill} -> ${action.agent}`;
250
+ const reservedTargetFailure = failReservedStateTarget(action, home);
251
+ if (reservedTargetFailure) {
252
+ if (reservedTargetFailure.outcome === "fail") {
253
+ logger.fail(label, reservedTargetFailure.error);
254
+ }
255
+ return reservedTargetFailure;
256
+ }
201
257
  try {
202
258
  await lstat(action.target);
203
259
  }
@@ -245,22 +301,9 @@ async function revertFrontmatterEmit(action, dryRun, verbose, home, planned, dep
245
301
  }
246
302
  async function executeRevertAction(action, dryRun, verbose, home, planned, deps) {
247
303
  const label = `${action.skill} -> ${action.agent}`;
248
- try {
249
- await lstat(action.target);
250
- }
251
- catch (err) {
252
- const result = lstatOutcome(err);
253
- if (result.outcome === "skip") {
254
- logger.skip(label, "(not found, skipping)");
255
- return result;
256
- }
257
- logger.fail(label, result.error);
258
- return result;
259
- }
260
- const entry = await lookupDeployment(home, action.target, deps.registry);
261
- if (!entry || entry.skill !== action.skill || entry.agent !== action.agent) {
262
- logger.warn(label, `skipping: ${action.target} is not in the deployment registry — not managed by inception-engine`);
263
- return { outcome: "skip" };
304
+ const preflight = await preflightManagedSkillDirRevert(action, label, home, deps);
305
+ if (preflight) {
306
+ return preflight;
264
307
  }
265
308
  if (dryRun) {
266
309
  planned.push({
@@ -302,6 +345,13 @@ async function executeRevertAction(action, dryRun, verbose, home, planned, deps)
302
345
  }
303
346
  async function revertFileWrite(action, dryRun, verbose, home, planned, deps) {
304
347
  const label = `${action.skill} -> ${action.agent}`;
348
+ const reservedTargetFailure = failReservedStateTarget(action, home);
349
+ if (reservedTargetFailure) {
350
+ if (reservedTargetFailure.outcome === "fail") {
351
+ logger.fail(label, reservedTargetFailure.error);
352
+ }
353
+ return reservedTargetFailure;
354
+ }
305
355
  try {
306
356
  await lstat(action.target);
307
357
  }
@@ -353,6 +403,13 @@ async function revertFileWrite(action, dryRun, verbose, home, planned, deps) {
353
403
  }
354
404
  async function revertConfigPatch(action, dryRun, verbose, home, planned, deps) {
355
405
  const label = `${action.skill} -> ${action.agent}`;
406
+ const reservedTargetFailure = failReservedStateTarget(action, home);
407
+ if (reservedTargetFailure) {
408
+ if (reservedTargetFailure.outcome === "fail") {
409
+ logger.fail(label, reservedTargetFailure.error);
410
+ }
411
+ return reservedTargetFailure;
412
+ }
356
413
  try {
357
414
  await lstat(action.target);
358
415
  }
@@ -404,6 +461,13 @@ async function revertConfigPatch(action, dryRun, verbose, home, planned, deps) {
404
461
  }
405
462
  async function revertTomlPatch(action, dryRun, verbose, home, planned, deps) {
406
463
  const label = `${action.skill} -> ${action.agent}`;
464
+ const reservedTargetFailure = failReservedStateTarget(action, home);
465
+ if (reservedTargetFailure) {
466
+ if (reservedTargetFailure.outcome === "fail") {
467
+ logger.fail(label, reservedTargetFailure.error);
468
+ }
469
+ return reservedTargetFailure;
470
+ }
407
471
  try {
408
472
  await lstat(action.target);
409
473
  }
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export declare function normalizeSlashes(value: string): string;
6
6
  /**
7
- * Assert that a path ends with the given POSIX-style suffix, normalizing separators.
8
- * Use instead of: assert.ok(normalizeSlashes(p).endsWith("some/suffix"))
7
+ * Assert that a path ends with the given suffix, normalizing separators on both
8
+ * sides so callers can safely use either literals or path.join(...).
9
9
  */
10
10
  export declare function assertPathEndsWith(actual: string, suffix: string, msg?: string): void;
@@ -7,9 +7,9 @@ export function normalizeSlashes(value) {
7
7
  return value.replaceAll("\\", "/");
8
8
  }
9
9
  /**
10
- * Assert that a path ends with the given POSIX-style suffix, normalizing separators.
11
- * Use instead of: assert.ok(normalizeSlashes(p).endsWith("some/suffix"))
10
+ * Assert that a path ends with the given suffix, normalizing separators on both
11
+ * sides so callers can safely use either literals or path.join(...).
12
12
  */
13
13
  export function assertPathEndsWith(actual, suffix, msg) {
14
- assert.ok(normalizeSlashes(actual).endsWith(suffix), msg ?? `Expected path "${actual}" to end with "${suffix}"`);
14
+ assert.ok(normalizeSlashes(actual).endsWith(normalizeSlashes(suffix)), msg ?? `Expected path "${actual}" to end with "${suffix}"`);
15
15
  }