portable-agent-layer 0.78.1 → 0.78.2

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": "portable-agent-layer",
3
- "version": "0.78.1",
3
+ "version": "0.78.2",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,6 +4,9 @@
4
4
  */
5
5
 
6
6
  import { lstatSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { resolve } from "node:path";
9
+ import { palHome, platform } from "./paths";
7
10
 
8
11
  // PowerShell aliases rm, rmdir, del, erase, rd and ri all to Remove-Item, and
9
12
  // cmd ships its own rd and del — so the verb alone never says which shell ran it.
@@ -137,13 +140,8 @@ const HOOK_MANAGED_DIRS = [
137
140
  "debug",
138
141
  ];
139
142
 
140
- /** Escape a string for use in a RegExp */
141
- function escapeRegExp(s: string): string {
142
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
143
- }
144
-
145
143
  /** PAL-deployed dirs — engine-managed, overwritten on every `pal install` */
146
- const PAL_INSTALLED_DIRS_RE = /[/\\]\.pal[/\\](?:docs|skills|tools)[/\\]/;
144
+ const PAL_INSTALLED_DIRS = ["docs", "skills", "tools"];
147
145
 
148
146
  /** Paths that should never be written to */
149
147
  const PROTECTED_PATHS: RegExp[] = [
@@ -154,25 +152,80 @@ const PROTECTED_PATHS: RegExp[] = [
154
152
  /\.gnupg\//,
155
153
  // Claude Code auto-memory — PAL owns memory; writes here indicate wrong system is being used
156
154
  /\.claude\/projects\/[^/]+\/memory\//,
157
- // PAL_INSTALLED_DIRS_RE is enforced by the dedicated branch in checkFilePath
158
- // (which exempts personal skill dirs); keeping it here would re-block them.
159
- // Derived from HOOK_MANAGED_FILES — scoped to managed roots only
160
- ...HOOK_MANAGED_FILES.map(
161
- (name) =>
162
- new RegExp(
163
- String.raw`[/\\]\.(?:pal|claude|agents|cursor)[/\\].*${escapeRegExp(name)}$`
164
- )
165
- ),
166
155
  ];
167
156
 
168
- /** Roots where managed files/dirs are protected (user state, not repo templates) */
169
- const MANAGED_ROOTS = [".pal/", ".claude/", ".agents/", ".config/opencode/", ".cursor/"];
157
+ function comparable(path: string): string {
158
+ const forward = path.replaceAll("\\", "/").replace(/\/+$/, "");
159
+ return process.platform === "win32" ? forward.toLowerCase() : forward;
160
+ }
161
+
162
+ function absoluteComparable(path: string): string {
163
+ return comparable(resolve(path.replaceAll("\\", "/")));
164
+ }
165
+
166
+ /** The part of `path` below `root`, or null when `path` is outside it. */
167
+ function pathBelow(root: string, path: string): string | null {
168
+ const base = absoluteComparable(root);
169
+ const target = absoluteComparable(path);
170
+ return target.startsWith(`${base}/`) ? target.slice(base.length + 1) : null;
171
+ }
172
+
173
+ /** The real directories PAL writes user state into, not any folder that shares their name. */
174
+ function managedRoots(): string[] {
175
+ return [
176
+ palHome(),
177
+ platform.claudeDir(),
178
+ platform.agentsDir(),
179
+ platform.cursorDir(),
180
+ platform.opencodeDir(),
181
+ ];
182
+ }
183
+
184
+ function pathInsideManagedRoot(path: string): string | null {
185
+ for (const root of managedRoots()) {
186
+ const below = pathBelow(root, path);
187
+ if (below !== null) return below;
188
+ }
189
+ return null;
190
+ }
170
191
 
171
- function isUnderManagedRoot(path: string): boolean {
172
- const normalized = path.replaceAll("\\", "/");
173
- return MANAGED_ROOTS.some(
174
- (root) => normalized.includes(`/${root}`) || normalized.includes(`\\.${root}`)
175
- );
192
+ function managedFileAt(below: string): string | undefined {
193
+ return HOOK_MANAGED_FILES.find((name) => {
194
+ const file = comparable(name);
195
+ return below === file || below.endsWith(`/${file}`);
196
+ });
197
+ }
198
+
199
+ function managedDirAt(below: string): string | undefined {
200
+ return HOOK_MANAGED_DIRS.find((dir) => `/${below}/`.includes(`/${comparable(dir)}/`));
201
+ }
202
+
203
+ function managedPathReason(path: string): string | null {
204
+ const below = pathInsideManagedRoot(path);
205
+ if (below === null) return null;
206
+ const file = managedFileAt(below);
207
+ if (file) return `${file} is managed automatically by hooks — do not edit directly`;
208
+ const dir = managedDirAt(below);
209
+ if (dir) return `${dir}/ is managed automatically by hooks — do not edit directly`;
210
+ return null;
211
+ }
212
+
213
+ const HOME_PREFIX =
214
+ /^(?:~|\$HOME|\$\{HOME\}|\$env:USERPROFILE|%USERPROFILE%)(?=[/\\]|$)/i;
215
+ const COMMAND_TOKEN = /[^\s'"`<>|;&()=]+/g;
216
+
217
+ function pathsNamedIn(segment: string): string[] {
218
+ return Array.from(segment.matchAll(COMMAND_TOKEN), (m) => m[0])
219
+ .filter((token) => HOME_PREFIX.test(token) || /[/\\]/.test(token))
220
+ .map((token) => token.replace(HOME_PREFIX, () => homedir()));
221
+ }
222
+
223
+ function managedReasonInSegment(segment: string): string | null {
224
+ for (const path of pathsNamedIn(segment)) {
225
+ const reason = managedPathReason(path);
226
+ if (reason) return reason;
227
+ }
228
+ return null;
176
229
  }
177
230
 
178
231
  /** Read-only commands allowed to reference protected files */
@@ -189,26 +242,10 @@ export function checkBashCommand(cmd: string): string | null {
189
242
  if (pattern.test(cmd)) return reason;
190
243
  }
191
244
  }
192
- // If command references a managed file in a managed root path, block unless read-only.
193
- // The filename must appear IN the same path as the managed root (e.g. .pal/.../file.json).
194
- const segments = cmd.split(/[|;&&]/).map((s) => s.trim());
195
- for (const name of HOOK_MANAGED_FILES) {
196
- const pattern = new RegExp(
197
- String.raw`\.(?:pal|claude|agents|cursor|config/opencode)[/\\]\S*${escapeRegExp(name)}`
198
- );
199
- const managed = segments.filter((s) => pattern.test(s));
200
- if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
201
- return `${name} is managed automatically by hooks — do not edit directly`;
202
- }
203
- }
204
- for (const dir of HOOK_MANAGED_DIRS) {
205
- const pattern = new RegExp(
206
- String.raw`\.(?:pal|claude|agents|cursor|config/opencode)[/\\]\S*${escapeRegExp(dir)}`
207
- );
208
- const managed = segments.filter((s) => pattern.test(s));
209
- if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
210
- return `${dir} is managed automatically by hooks — do not edit directly`;
211
- }
245
+ const segments = cmd.split(/[|;&]/).map((s) => s.trim());
246
+ for (const segment of segments) {
247
+ const reason = managedReasonInSegment(segment);
248
+ if (reason && !READ_ONLY_COMMANDS.test(segment)) return reason;
212
249
  }
213
250
  return null;
214
251
  }
@@ -218,44 +255,25 @@ export function checkBashCommand(cmd: string): string | null {
218
255
  * skills are real dirs authored in place. A not-yet-created skill dir is also
219
256
  * personal (scaffolding). So: symlink → shipped/protected, otherwise → personal.
220
257
  */
221
- function isShippedSkillPath(normalized: string): boolean {
222
- const m = /\.pal\/skills\/([^/]+)/.exec(normalized);
223
- if (!m) return false;
224
- const skillRoot = normalized.slice(0, m.index + m[0].length);
258
+ function isShippedSkill(name: string): boolean {
225
259
  try {
226
- return lstatSync(skillRoot).isSymbolicLink();
260
+ return lstatSync(resolve(palHome(), "skills", name)).isSymbolicLink();
227
261
  } catch {
228
262
  return false;
229
263
  }
230
264
  }
231
265
 
266
+ function palInstalledReason(path: string): string | null {
267
+ const [dir, entry] = pathBelow(palHome(), path)?.split("/") ?? [];
268
+ if (!dir || !entry || !PAL_INSTALLED_DIRS.includes(dir)) return null;
269
+ if (dir === "skills" && !isShippedSkill(entry)) return null;
270
+ return `~/.pal/${dir}/ is managed by 'pal install' — edit the source in the PAL repo instead`;
271
+ }
272
+
232
273
  /** Check a file path against protected patterns. Returns a reason string or null. */
233
274
  export function checkFilePath(filePath: string): string | null {
234
- const normalized = filePath.replaceAll("\\", "/");
235
- // Check hook-managed files — only under managed roots (not repo templates)
236
- if (isUnderManagedRoot(normalized)) {
237
- const matchedFile = HOOK_MANAGED_FILES.find((name) =>
238
- normalized.endsWith(`/${name}`)
239
- );
240
- if (matchedFile) {
241
- return `${matchedFile} is managed automatically by hooks — do not edit directly`;
242
- }
243
- }
244
- // Check hook-managed directories
245
- const matchedDir = HOOK_MANAGED_DIRS.find((dir) => normalized.includes(`/${dir}/`));
246
- if (matchedDir) {
247
- return `${matchedDir}/ is managed automatically by hooks — do not edit directly`;
248
- }
249
- // PAL-deployed dirs — edit source in the PAL repo, not the installed copy
250
- if (PAL_INSTALLED_DIRS_RE.test(normalized)) {
251
- const match = new RegExp(/\.pal[/\\](docs|skills|tools)/).exec(normalized);
252
- const dir = match ? match[1] : "docs/skills/tools";
253
- const isPersonalSkill = dir === "skills" && !isShippedSkillPath(normalized);
254
- if (!isPersonalSkill) {
255
- return `~/.pal/${dir}/ is managed by 'pal install' — edit the source in the PAL repo instead`;
256
- }
257
- }
258
- // Check remaining system-protected paths
275
+ const reason = managedPathReason(filePath) ?? palInstalledReason(filePath);
276
+ if (reason) return reason;
259
277
  if (PROTECTED_PATHS.some((pattern) => pattern.test(filePath))) {
260
278
  return `Protected path: ${filePath}`;
261
279
  }