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 +1 -1
- package/src/hooks/lib/security.ts +90 -72
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
169
|
-
const
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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(
|
|
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
|
|
235
|
-
|
|
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
|
}
|