@yagni-app/code-staging 0.3.0-staging.1064.1 → 0.3.0-staging.1071.1
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/dist/extension/chipEditor.d.ts +20 -8
- package/dist/extension/chipEditor.js +140 -58
- package/dist/extension/execPolicy.d.ts +73 -0
- package/dist/extension/execPolicy.js +399 -0
- package/dist/extension/guardian.d.ts +107 -0
- package/dist/extension/guardian.js +175 -0
- package/dist/extension/index.d.ts +5 -1
- package/dist/extension/index.js +27 -2
- package/dist/extension/permission.d.ts +48 -6
- package/dist/extension/permission.js +233 -24
- package/dist/extension/pipeline/personas.js +22 -0
- package/dist/promptEnrichment.d.ts +1 -1
- package/dist/promptEnrichment.js +17 -0
- package/package.json +2 -2
|
@@ -48,21 +48,33 @@ export interface ImageAttachment {
|
|
|
48
48
|
*/
|
|
49
49
|
export declare function unwrapBracketedPaste(data: string): string | null;
|
|
50
50
|
/**
|
|
51
|
-
* Recognize a pasted
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
51
|
+
* Recognize a pasted payload that is entirely image-file paths and decode
|
|
52
|
+
* them. This covers Finder/desktop drag-drop and Ghostty/cmux's Cmd+V
|
|
53
|
+
* (which writes the clipboard image to a `clipboard-*.png` temp file and
|
|
54
|
+
* pastes its path) — Claude Code parity: dragging a screenshot in becomes
|
|
55
|
+
* `[Image #N]`, not a literal path.
|
|
56
|
+
*
|
|
57
|
+
* Guardrails against converting something the user meant as text: the WHOLE
|
|
58
|
+
* paste must be path tokens, every token must be an absolute path to a real
|
|
59
|
+
* file with an image extension AND genuine image magic bytes, and the
|
|
60
|
+
* conversion is visible — each image becomes a chip in the prompt, so nothing
|
|
61
|
+
* is ever attached silently. Returns null when the paste is not such a list
|
|
62
|
+
* (caller passes it through as ordinary text).
|
|
57
63
|
*/
|
|
58
|
-
export declare function
|
|
64
|
+
export declare function readPastedImagePaths(pastedText: string): ClipboardImage[] | null;
|
|
59
65
|
/**
|
|
60
66
|
* Read an image from the system clipboard without forking pi. Best-effort and
|
|
61
|
-
* cross-platform
|
|
67
|
+
* cross-platform using ONLY tools the OS ships with: osascript (macOS),
|
|
62
68
|
* wl-paste (Wayland), xclip (X11), PowerShell (Windows/WSL). Returns null when
|
|
63
69
|
* the clipboard holds no image (caller then pastes text instead).
|
|
64
70
|
*/
|
|
65
71
|
export declare const defaultClipboardImageReader: ClipboardImageReader;
|
|
72
|
+
/**
|
|
73
|
+
* Parse osascript's clipboard dump — `«data PNGf89504E47…»` — into raw bytes.
|
|
74
|
+
* The 4-char tag after `«data ` is the pasteboard flavor; the hex that follows
|
|
75
|
+
* is the image. Returns null when the output holds no such dump.
|
|
76
|
+
*/
|
|
77
|
+
export declare function parseOsascriptImageHex(out: string): Uint8Array | null;
|
|
66
78
|
/**
|
|
67
79
|
* A CustomEditor that turns clipboard image paste into numbered `[Image #N]`
|
|
68
80
|
* chips. The image bytes live here, keyed by chip number; the editor text holds
|
|
@@ -64,58 +64,98 @@ const IMAGE_MAGIC = [
|
|
|
64
64
|
[[0x47, 0x49, 0x46, 0x38], "image/gif"], // GIF8
|
|
65
65
|
[[0x52, 0x49, 0x46, 0x46], "image/webp"], // RIFF (webp container)
|
|
66
66
|
];
|
|
67
|
+
/** A text paste this long is prose, not a dragged file list — bail early. */
|
|
68
|
+
const MAX_PASTED_PATHS = 20;
|
|
67
69
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* Returns the decoded image, or null when the pasted text is not one such path.
|
|
70
|
+
* Split a pasted payload into path tokens the way terminals produce them:
|
|
71
|
+
* Finder/desktop drag-drop inserts absolute paths with backslash-escaped
|
|
72
|
+
* spaces (POSIX) or quote-wrapped paths (Windows Terminal), multiple files
|
|
73
|
+
* separated by whitespace. Returns [] when the payload can't be a path list
|
|
74
|
+
* (e.g. an unbalanced quote from ordinary prose like "don't").
|
|
74
75
|
*/
|
|
75
|
-
|
|
76
|
-
const trimmed = pastedText.trim();
|
|
77
|
-
if (trimmed.includes("\n") || trimmed.includes(" "))
|
|
78
|
-
return null; // one token only
|
|
79
|
-
// Validate the RAW input first: an attacker could smuggle the `clipboard-`
|
|
80
|
-
// prefix through backslash escapes (e.g. `\c\l\i\p\b\o\a\r\d-`), so we
|
|
81
|
-
// must confirm the un-escaped form is a well-formed tmp image path before any
|
|
82
|
-
// unescaping or path resolution. cmux tmp paths contain no escapable chars
|
|
83
|
-
// (no spaces/metacharacters), so a legitimate paste has NO backslashes at all.
|
|
84
|
-
// On Windows the backslash IS the path separator (and cmd/PowerShell do no
|
|
85
|
-
// backslash-escaping), so the escape-smuggling rejection applies only where
|
|
86
|
-
// a backslash could be an escape: POSIX shells.
|
|
76
|
+
function splitPathTokens(text) {
|
|
87
77
|
const isWindows = process.platform === "win32";
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
78
|
+
const tokens = [];
|
|
79
|
+
let cur = "";
|
|
80
|
+
let quote = null;
|
|
81
|
+
for (let i = 0; i < text.length; i++) {
|
|
82
|
+
const ch = text[i];
|
|
83
|
+
if (quote) {
|
|
84
|
+
if (ch === quote)
|
|
85
|
+
quote = null;
|
|
86
|
+
else
|
|
87
|
+
cur += ch;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (ch === '"' || ch === "'") {
|
|
91
|
+
quote = ch;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// POSIX shells escape spaces/metacharacters with a backslash; on Windows
|
|
95
|
+
// the backslash IS the path separator, so no unescaping there.
|
|
96
|
+
if (!isWindows && ch === "\\" && i + 1 < text.length) {
|
|
97
|
+
cur += text[++i];
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
|
|
101
|
+
if (cur)
|
|
102
|
+
tokens.push(cur);
|
|
103
|
+
cur = "";
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
cur += ch;
|
|
107
107
|
}
|
|
108
|
-
if (
|
|
108
|
+
if (quote !== null)
|
|
109
|
+
return [];
|
|
110
|
+
if (cur)
|
|
111
|
+
tokens.push(cur);
|
|
112
|
+
return tokens;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Recognize a pasted payload that is entirely image-file paths and decode
|
|
116
|
+
* them. This covers Finder/desktop drag-drop and Ghostty/cmux's Cmd+V
|
|
117
|
+
* (which writes the clipboard image to a `clipboard-*.png` temp file and
|
|
118
|
+
* pastes its path) — Claude Code parity: dragging a screenshot in becomes
|
|
119
|
+
* `[Image #N]`, not a literal path.
|
|
120
|
+
*
|
|
121
|
+
* Guardrails against converting something the user meant as text: the WHOLE
|
|
122
|
+
* paste must be path tokens, every token must be an absolute path to a real
|
|
123
|
+
* file with an image extension AND genuine image magic bytes, and the
|
|
124
|
+
* conversion is visible — each image becomes a chip in the prompt, so nothing
|
|
125
|
+
* is ever attached silently. Returns null when the paste is not such a list
|
|
126
|
+
* (caller passes it through as ordinary text).
|
|
127
|
+
*/
|
|
128
|
+
export function readPastedImagePaths(pastedText) {
|
|
129
|
+
const tokens = splitPathTokens(pastedText.trim());
|
|
130
|
+
if (tokens.length === 0 || tokens.length > MAX_PASTED_PATHS)
|
|
109
131
|
return null;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
132
|
+
const isWindows = process.platform === "win32";
|
|
133
|
+
const images = [];
|
|
134
|
+
for (const path of tokens) {
|
|
135
|
+
if (isWindows ? !isAbsolute(path) : !path.startsWith("/"))
|
|
136
|
+
return null;
|
|
137
|
+
const ext = basename(path).split(".").pop()?.toLowerCase() ?? "";
|
|
138
|
+
if (!(ext in IMAGE_EXT_MIME))
|
|
139
|
+
return null;
|
|
140
|
+
if (!existsSync(path))
|
|
141
|
+
return null;
|
|
142
|
+
let bytes;
|
|
143
|
+
try {
|
|
144
|
+
bytes = readFileSync(path);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const magic = IMAGE_MAGIC.find(([m]) => m.every((b, i) => bytes[i] === b));
|
|
150
|
+
if (!magic)
|
|
151
|
+
return null;
|
|
152
|
+
images.push({ bytes, mimeType: magic[1] });
|
|
113
153
|
}
|
|
114
|
-
return
|
|
154
|
+
return images;
|
|
115
155
|
}
|
|
116
156
|
/**
|
|
117
157
|
* Read an image from the system clipboard without forking pi. Best-effort and
|
|
118
|
-
* cross-platform
|
|
158
|
+
* cross-platform using ONLY tools the OS ships with: osascript (macOS),
|
|
119
159
|
* wl-paste (Wayland), xclip (X11), PowerShell (Windows/WSL). Returns null when
|
|
120
160
|
* the clipboard holds no image (caller then pastes text instead).
|
|
121
161
|
*/
|
|
@@ -145,7 +185,45 @@ function runBase64(command, args, timeoutMs = 3000) {
|
|
|
145
185
|
return out.length > 0 ? out : null;
|
|
146
186
|
}
|
|
147
187
|
function readMacClipboardImage() {
|
|
148
|
-
|
|
188
|
+
return readMacClipboardViaOsascript() ?? readMacClipboardViaPngpaste();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Parse osascript's clipboard dump — `«data PNGf89504E47…»` — into raw bytes.
|
|
192
|
+
* The 4-char tag after `«data ` is the pasteboard flavor; the hex that follows
|
|
193
|
+
* is the image. Returns null when the output holds no such dump.
|
|
194
|
+
*/
|
|
195
|
+
export function parseOsascriptImageHex(out) {
|
|
196
|
+
const m = /«data \w{4}((?:[0-9A-Fa-f]{2})+)»/.exec(out);
|
|
197
|
+
if (!m)
|
|
198
|
+
return null;
|
|
199
|
+
const bytes = Buffer.from(m[1], "hex");
|
|
200
|
+
return bytes.length > 0 ? bytes : null;
|
|
201
|
+
}
|
|
202
|
+
function readMacClipboardViaOsascript() {
|
|
203
|
+
// AppleScript ships with macOS, so this route needs no install (it is how
|
|
204
|
+
// Claude Code reads clipboard images too). Screenshots and browser
|
|
205
|
+
// "Copy image" put a PNG flavor on the pasteboard; try JPEG second.
|
|
206
|
+
for (const [cls, mime] of [
|
|
207
|
+
["PNGf", "image/png"],
|
|
208
|
+
["JPEG", "image/jpeg"],
|
|
209
|
+
]) {
|
|
210
|
+
const res = spawnSync("osascript", ["-e", `the clipboard as «class ${cls}»`], {
|
|
211
|
+
timeout: 5000,
|
|
212
|
+
maxBuffer: 256 * 1024 * 1024, // the hex dump doubles the image size
|
|
213
|
+
});
|
|
214
|
+
if (res.error || res.status !== 0)
|
|
215
|
+
continue;
|
|
216
|
+
const out = Buffer.isBuffer(res.stdout) ? res.stdout.toString("utf8") : String(res.stdout ?? "");
|
|
217
|
+
const bytes = parseOsascriptImageHex(out);
|
|
218
|
+
if (bytes)
|
|
219
|
+
return { bytes, mimeType: mime };
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
function readMacClipboardViaPngpaste() {
|
|
224
|
+
// Optional fallback for pasteboard flavors osascript can't coerce to
|
|
225
|
+
// PNG/JPEG (e.g. TIFF-only sources) — pngpaste converts anything to PNG.
|
|
226
|
+
// Nice when installed, never required.
|
|
149
227
|
const dest = join(tmpdir(), `yagni-clip-${randomUUID()}.png`);
|
|
150
228
|
try {
|
|
151
229
|
const res = spawnSync("pngpaste", [dest], { timeout: 3000 });
|
|
@@ -222,27 +300,31 @@ export class ChipEditor extends CustomEditor {
|
|
|
222
300
|
* Kitty-protocol terminals that genuinely deliver the Cmd modifier.
|
|
223
301
|
*/
|
|
224
302
|
handleInput(data) {
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// so we strip the markers here and
|
|
303
|
+
// Pasted image PATHS arrive as bracketed text ("\x1b[200~<paths>\x1b[201~",
|
|
304
|
+
// pi's Terminal re-wraps every paste): Finder/desktop drag-drop inserts the
|
|
305
|
+
// file's path, and Ghostty-based terminals (cmux, Ghostty) write a Cmd+V'd
|
|
306
|
+
// clipboard image to a temp file and paste ITS path. handlePaste is private
|
|
307
|
+
// and consumed inside super.handleInput, so we strip the markers here and
|
|
308
|
+
// convert an all-image-paths payload into chips.
|
|
230
309
|
const unwrapped = unwrapBracketedPaste(data);
|
|
231
310
|
if (unwrapped !== null) {
|
|
232
|
-
const
|
|
311
|
+
const images = readPastedImagePaths(unwrapped);
|
|
233
312
|
logImagePaste({
|
|
234
313
|
event: "paste_path",
|
|
235
|
-
outcome:
|
|
236
|
-
|
|
237
|
-
bytes:
|
|
238
|
-
|
|
239
|
-
detail: image ? undefined : `not a lone image path: ${unwrapped.slice(0, 60)}`,
|
|
314
|
+
outcome: images ? "ok" : "passthrough",
|
|
315
|
+
imageCount: images?.length,
|
|
316
|
+
bytes: images?.reduce((sum, i) => sum + i.bytes.length, 0),
|
|
317
|
+
detail: images ? undefined : `not an image path list: ${unwrapped.slice(0, 60)}`,
|
|
240
318
|
});
|
|
241
|
-
if (
|
|
242
|
-
|
|
319
|
+
if (images) {
|
|
320
|
+
images.forEach((image, i) => {
|
|
321
|
+
if (i > 0)
|
|
322
|
+
this.insertTextAtCursor(" ");
|
|
323
|
+
this.dropChip(image.bytes, image.mimeType);
|
|
324
|
+
});
|
|
243
325
|
return;
|
|
244
326
|
}
|
|
245
|
-
// Not a
|
|
327
|
+
// Not a list of image paths — re-wrap and let pi handle the paste normally.
|
|
246
328
|
super.handleInput(data);
|
|
247
329
|
return;
|
|
248
330
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exec policy engine — classifies bash commands via prefix rules + lightweight
|
|
3
|
+
* shell tokenization (YAG-504).
|
|
4
|
+
*
|
|
5
|
+
* Pure: no I/O, no network, no model. Loads at startup and classifies
|
|
6
|
+
* synchronously. The curated default set auto-allows read-only commands
|
|
7
|
+
* (ls, cat, rg, git status/log/diff), forbids destructive ones (rm -rf,
|
|
8
|
+
* git reset --hard, git push --force, pipe-to-shell), and prompts for the
|
|
9
|
+
* ambiguous middle band (npm install, git commit, curl, …).
|
|
10
|
+
*
|
|
11
|
+
* The `prompt` band is what the Guardian arbitrates — see guardian.ts.
|
|
12
|
+
*
|
|
13
|
+
* Tokenization is a lightweight inline parser — not shell-quote — because the
|
|
14
|
+
* extension is bundled into @yagni-app/code's dist (a file copy, not a real
|
|
15
|
+
* bundler), and external dependencies aren't resolvable from the bundled path.
|
|
16
|
+
* We only need: split on whitespace (respecting single/double quotes), detect
|
|
17
|
+
* control operators (|, &&, ||, ;), and flag shell constructs ($(...),
|
|
18
|
+
* backticks, redirects) that we can't statically analyze.
|
|
19
|
+
*/
|
|
20
|
+
export type TokenEntry = string | {
|
|
21
|
+
op: "pipe" | "and" | "or" | "semi" | "redirect" | "substitution";
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Parse a shell command string into tokens and control operators.
|
|
25
|
+
*
|
|
26
|
+
* Handles:
|
|
27
|
+
* - Single and double quoted strings (preserves spaces inside)
|
|
28
|
+
* - Control operators: |, &&, ||, ;
|
|
29
|
+
* - Shell constructs we flag as unanalyzable: $(), backticks, >, <
|
|
30
|
+
*
|
|
31
|
+
* Does NOT handle: variable expansion, glob patterns, heredocs, nested
|
|
32
|
+
* subshells beyond the first level. Commands using those are classified
|
|
33
|
+
* as "prompt" (let the Guardian review).
|
|
34
|
+
*/
|
|
35
|
+
export declare function shellParse(command: string): TokenEntry[];
|
|
36
|
+
export type ExecDecision = "allow" | "prompt" | "forbidden";
|
|
37
|
+
export interface PrefixRule {
|
|
38
|
+
/** Ordered tokens; a string[] element means alternatives (any match). */
|
|
39
|
+
pattern: (string | string[])[];
|
|
40
|
+
decision: ExecDecision;
|
|
41
|
+
justification: string;
|
|
42
|
+
/** Positive test invocations (validated at load if present). */
|
|
43
|
+
match?: string[][];
|
|
44
|
+
/** Negative test invocations (validated at load if present). */
|
|
45
|
+
notMatch?: string[][];
|
|
46
|
+
}
|
|
47
|
+
export interface ExecPolicy {
|
|
48
|
+
rules: PrefixRule[];
|
|
49
|
+
}
|
|
50
|
+
export interface ExecClassification {
|
|
51
|
+
decision: ExecDecision;
|
|
52
|
+
justification: string;
|
|
53
|
+
matchedRule?: PrefixRule;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parse a command string into tokens using our lightweight tokenizer. Returns
|
|
57
|
+
* string tokens only (control operators and constructs are filtered out —
|
|
58
|
+
* detected separately).
|
|
59
|
+
*/
|
|
60
|
+
export declare function tokenize(command: string): string[];
|
|
61
|
+
/**
|
|
62
|
+
* Classify a full bash command string against the exec policy.
|
|
63
|
+
*
|
|
64
|
+
* Compound commands (pipes, &&, ||, ;) are split into segments and each is
|
|
65
|
+
* classified independently. The strictest decision wins (forbidden > prompt >
|
|
66
|
+
* allow). Commands with shell constructs we can't parse (command substitution,
|
|
67
|
+
* redirects beyond pipe) are classified as prompt. Pipe-to-shell is always
|
|
68
|
+
* forbidden.
|
|
69
|
+
*/
|
|
70
|
+
export declare function classifyCommand(command: string, policy: ExecPolicy): ExecClassification;
|
|
71
|
+
/** Curated default rules — the shipped safety floor. */
|
|
72
|
+
export declare const DEFAULT_EXEC_POLICY: ExecPolicy;
|
|
73
|
+
//# sourceMappingURL=execPolicy.d.ts.map
|