fix: strip hidden unicode from imported context

This commit is contained in:
artale 2026-06-15 04:35:47 +02:00
parent 5a38f57db7
commit 4499b860be
4 changed files with 32 additions and 7 deletions

View File

@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { hasHiddenUnicode, stripHiddenUnicode } from "./unicode-safety.js";
describe("unicode safety", () => {
it("strips hidden instruction carriers", () => {
const tag = String.fromCodePoint(0xe0069);
const text = `safe${tag}\u200b\u202etext`;
expect(hasHiddenUnicode(text)).toBe(true);
expect(stripHiddenUnicode(text)).toBe("safetext");
});
});

View File

@ -0,0 +1,11 @@
const HIDDEN_UNICODE = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFE00-\uFE0F\u{E0000}-\u{E007F}]/gu;
export function stripHiddenUnicode(text: string): string {
// ponytail: strips known invisible instruction carriers; add reporting metadata if UI needs forensics.
return text.replace(HIDDEN_UNICODE, "");
}
export function hasHiddenUnicode(text: string): boolean {
HIDDEN_UNICODE.lastIndex = 0;
return HIDDEN_UNICODE.test(text);
}

View File

@ -1,6 +1,7 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import * as os from "node:os"; import * as os from "node:os";
import { stripHiddenUnicode } from "../core/unicode-safety.js";
const PAI_ROOT = path.join(os.homedir(), ".claude", "PAI"); const PAI_ROOT = path.join(os.homedir(), ".claude", "PAI");
@ -55,7 +56,7 @@ export class PaiConnector {
return { return {
path: full, path: full,
name: f.replace(/\.md$/, ""), name: f.replace(/\.md$/, ""),
content: fs.readFileSync(full, "utf-8"), content: stripHiddenUnicode(fs.readFileSync(full, "utf-8")),
}; };
}); });
} }
@ -70,7 +71,7 @@ export class PaiConnector {
return fs.statSync(skillPath).isDirectory() && fs.existsSync(path.join(skillPath, "SKILL.md")); return fs.statSync(skillPath).isDirectory() && fs.existsSync(path.join(skillPath, "SKILL.md"));
}).map((f) => { }).map((f) => {
const skillPath = path.join(legacyDir, f); const skillPath = path.join(legacyDir, f);
const skillMd = fs.readFileSync(path.join(skillPath, "SKILL.md"), "utf-8"); const skillMd = stripHiddenUnicode(fs.readFileSync(path.join(skillPath, "SKILL.md"), "utf-8"));
const desc = skillMd.split("\n").slice(0, 5).join(" ").slice(0, 200); const desc = skillMd.split("\n").slice(0, 5).join(" ").slice(0, 200);
return { name: f, path: skillPath, description: desc }; return { name: f, path: skillPath, description: desc };
}); });
@ -81,7 +82,7 @@ export class PaiConnector {
return fs.statSync(skillPath).isDirectory() && fs.existsSync(path.join(skillPath, "SKILL.md")); return fs.statSync(skillPath).isDirectory() && fs.existsSync(path.join(skillPath, "SKILL.md"));
}).map((f) => { }).map((f) => {
const skillPath = path.join(skillsDir, f); const skillPath = path.join(skillsDir, f);
const skillMd = fs.readFileSync(path.join(skillPath, "SKILL.md"), "utf-8"); const skillMd = stripHiddenUnicode(fs.readFileSync(path.join(skillPath, "SKILL.md"), "utf-8"));
const desc = skillMd.split("\n").slice(0, 5).join(" ").slice(0, 200); const desc = skillMd.split("\n").slice(0, 5).join(" ").slice(0, 200);
return { name: f, path: skillPath, description: desc }; return { name: f, path: skillPath, description: desc };
}); });
@ -107,7 +108,7 @@ export class PaiConnector {
if (isaFiles.length === 0) return null; if (isaFiles.length === 0) return null;
const latest = isaFiles.sort((a, b) => b.name.localeCompare(a.name))[0]; const latest = isaFiles.sort((a, b) => b.name.localeCompare(a.name))[0];
const content = fs.readFileSync(latest.path, "utf-8"); const content = stripHiddenUnicode(fs.readFileSync(latest.path, "utf-8"));
const sections = this.parseSections(content); const sections = this.parseSections(content);
return { path: latest.path, name: latest.name, content, sections }; return { path: latest.path, name: latest.name, content, sections };
@ -116,7 +117,7 @@ export class PaiConnector {
readFile(relativePath: string): string | null { readFile(relativePath: string): string | null {
const full = path.join(this.paiRoot, relativePath); const full = path.join(this.paiRoot, relativePath);
if (!fs.existsSync(full)) return null; if (!fs.existsSync(full)) return null;
return fs.readFileSync(full, "utf-8"); return stripHiddenUnicode(fs.readFileSync(full, "utf-8"));
} }
private parseSections(content: string): Map<string, string> { private parseSections(content: string): Map<string, string> {

View File

@ -3,6 +3,7 @@ import * as path from "node:path";
import * as os from "node:os"; import * as os from "node:os";
import type { SkillDefinition, SkillStep } from "../core/types.js"; import type { SkillDefinition, SkillStep } from "../core/types.js";
import { StateStore } from "../core/state-store.js"; import { StateStore } from "../core/state-store.js";
import { stripHiddenUnicode } from "../core/unicode-safety.js";
const PAI_SKILLS_DIR = path.join(os.homedir(), ".claude", "skills"); const PAI_SKILLS_DIR = path.join(os.homedir(), ".claude", "skills");
@ -31,7 +32,7 @@ export class SkillSync {
const skillMd = path.join(this.paiSkillsDir, entry.name, "SKILL.md"); const skillMd = path.join(this.paiSkillsDir, entry.name, "SKILL.md");
if (!fs.existsSync(skillMd)) continue; if (!fs.existsSync(skillMd)) continue;
const content = fs.readFileSync(skillMd, "utf-8"); const content = stripHiddenUnicode(fs.readFileSync(skillMd, "utf-8"));
const description = this.extractDescription(content); const description = this.extractDescription(content);
const steps = this.extractSteps(content); const steps = this.extractSteps(content);
const tags = this.extractTags(content); const tags = this.extractTags(content);
@ -118,7 +119,7 @@ export class SkillSync {
const skillPath = path.join(this.paiSkillsDir, skillName, "SKILL.md"); const skillPath = path.join(this.paiSkillsDir, skillName, "SKILL.md");
if (!fs.existsSync(skillPath)) return null; if (!fs.existsSync(skillPath)) return null;
let content = fs.readFileSync(skillPath, "utf-8"); let content = stripHiddenUnicode(fs.readFileSync(skillPath, "utf-8"));
// Find or create a Lessons Learned section // Find or create a Lessons Learned section
const lessonsHeader = "## Lessons Learned"; const lessonsHeader = "## Lessons Learned";