pi-chassis 0.0.0-stage → 0.7.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/CHANGELOG.md +118 -0
- package/LICENSE +21 -0
- package/README.md +51 -2
- package/THIRD_PARTY_NOTICES +30 -0
- package/dist/chassis/alert.d.ts +50 -0
- package/dist/chassis/alert.js +107 -0
- package/dist/chassis/api.d.ts +411 -0
- package/dist/chassis/api.js +676 -0
- package/dist/chassis/bash-output.d.ts +89 -0
- package/dist/chassis/bash-output.js +157 -0
- package/dist/chassis/brand.d.ts +99 -0
- package/dist/chassis/brand.js +79 -0
- package/dist/chassis/client.d.ts +87 -0
- package/dist/chassis/client.js +271 -0
- package/dist/chassis/commands.d.ts +60 -0
- package/dist/chassis/commands.js +211 -0
- package/dist/chassis/editor.d.ts +70 -0
- package/dist/chassis/editor.js +213 -0
- package/dist/chassis/footer.d.ts +14 -0
- package/dist/chassis/footer.js +136 -0
- package/dist/chassis/header.d.ts +12 -0
- package/dist/chassis/header.js +38 -0
- package/dist/chassis/home.d.ts +48 -0
- package/dist/chassis/home.js +184 -0
- package/dist/chassis/icons.d.ts +203 -0
- package/dist/chassis/icons.js +190 -0
- package/dist/chassis/index.d.ts +21 -0
- package/dist/chassis/index.js +582 -0
- package/dist/chassis/layout.d.ts +40 -0
- package/dist/chassis/layout.js +264 -0
- package/dist/chassis/message-modes.d.ts +21 -0
- package/dist/chassis/message-modes.js +49 -0
- package/dist/chassis/messages.d.ts +75 -0
- package/dist/chassis/messages.js +392 -0
- package/dist/chassis/modes.d.ts +21 -0
- package/dist/chassis/modes.js +99 -0
- package/dist/chassis/palette.d.ts +84 -0
- package/dist/chassis/palette.js +235 -0
- package/dist/chassis/peek.d.ts +61 -0
- package/dist/chassis/peek.js +156 -0
- package/dist/chassis/prompt-row.d.ts +42 -0
- package/dist/chassis/prompt-row.js +68 -0
- package/dist/chassis/provider-view.d.ts +34 -0
- package/dist/chassis/provider-view.js +107 -0
- package/dist/chassis/provider.d.ts +69 -0
- package/dist/chassis/provider.js +73 -0
- package/dist/chassis/readonly-command.d.ts +19 -0
- package/dist/chassis/readonly-command.js +454 -0
- package/dist/chassis/sanitize.d.ts +42 -0
- package/dist/chassis/sanitize.js +103 -0
- package/dist/chassis/session-info.d.ts +39 -0
- package/dist/chassis/session-info.js +107 -0
- package/dist/chassis/sidebar.d.ts +48 -0
- package/dist/chassis/sidebar.js +273 -0
- package/dist/chassis/state.d.ts +38 -0
- package/dist/chassis/state.js +19 -0
- package/dist/chassis/telemetry.d.ts +60 -0
- package/dist/chassis/telemetry.js +191 -0
- package/dist/chassis/tools.d.ts +112 -0
- package/dist/chassis/tools.js +588 -0
- package/dist/chassis/version.d.ts +2 -0
- package/dist/chassis/version.js +20 -0
- package/dist/diff/git-diff.d.ts +71 -0
- package/dist/diff/git-diff.js +325 -0
- package/dist/diff/host.d.ts +34 -0
- package/dist/diff/host.js +50 -0
- package/dist/diff/index.d.ts +13 -0
- package/dist/diff/index.js +86 -0
- package/dist/diff/render.d.ts +67 -0
- package/dist/diff/render.js +196 -0
- package/dist/diff/types.d.ts +60 -0
- package/dist/diff/types.js +4 -0
- package/dist/diff/view.d.ts +118 -0
- package/dist/diff/view.js +528 -0
- package/dist/git/flows.d.ts +92 -0
- package/dist/git/flows.js +415 -0
- package/dist/git/forge.d.ts +26 -0
- package/dist/git/forge.js +41 -0
- package/dist/git/index.d.ts +20 -0
- package/dist/git/index.js +121 -0
- package/dist/git/repo.d.ts +64 -0
- package/dist/git/repo.js +186 -0
- package/dist/git/run.d.ts +38 -0
- package/dist/git/run.js +127 -0
- package/dist/git/settings.d.ts +8 -0
- package/dist/git/settings.js +38 -0
- package/dist/git/text.d.ts +51 -0
- package/dist/git/text.js +109 -0
- package/dist/widgets/container/detect.d.ts +23 -0
- package/dist/widgets/container/detect.js +67 -0
- package/dist/widgets/container/index.d.ts +9 -0
- package/dist/widgets/container/index.js +26 -0
- package/dist/widgets/mcp/index.d.ts +13 -0
- package/dist/widgets/mcp/index.js +50 -0
- package/dist/widgets/mcp/status.d.ts +50 -0
- package/dist/widgets/mcp/status.js +70 -0
- package/dist/widgets/session-todos/index.d.ts +12 -0
- package/dist/widgets/session-todos/index.js +52 -0
- package/dist/widgets/subagents/index.d.ts +16 -0
- package/dist/widgets/subagents/index.js +173 -0
- package/dist/widgets/subagents/status.d.ts +61 -0
- package/dist/widgets/subagents/status.js +198 -0
- package/dist/widgets/todo/index.d.ts +13 -0
- package/dist/widgets/todo/index.js +65 -0
- package/dist/widgets/todo/parse.d.ts +29 -0
- package/dist/widgets/todo/parse.js +122 -0
- package/docs/branding.md +25 -0
- package/docs/development.md +22 -0
- package/docs/diff-api.md +18 -0
- package/docs/git.md +23 -0
- package/docs/internals.md +13 -0
- package/docs/provider-status.md +23 -0
- package/docs/status-widgets.md +157 -0
- package/docs/tool-rows.md +50 -0
- package/docs/usage.md +62 -0
- package/docs/widgets.md +113 -0
- package/extensions/chassis/alert.ts +127 -0
- package/extensions/chassis/api.ts +967 -0
- package/extensions/chassis/bash-output.ts +186 -0
- package/extensions/chassis/brand.ts +151 -0
- package/extensions/chassis/client.ts +284 -0
- package/extensions/chassis/commands.ts +244 -0
- package/extensions/chassis/editor.ts +253 -0
- package/extensions/chassis/footer.ts +131 -0
- package/extensions/chassis/header.ts +43 -0
- package/extensions/chassis/home.ts +186 -0
- package/extensions/chassis/icons.ts +220 -0
- package/extensions/chassis/index.ts +605 -0
- package/extensions/chassis/layout.ts +298 -0
- package/extensions/chassis/message-modes.ts +48 -0
- package/extensions/chassis/messages.ts +403 -0
- package/extensions/chassis/modes.ts +108 -0
- package/extensions/chassis/palette.ts +258 -0
- package/extensions/chassis/peek.ts +167 -0
- package/extensions/chassis/prompt-row.ts +86 -0
- package/extensions/chassis/provider-view.ts +118 -0
- package/extensions/chassis/provider.ts +115 -0
- package/extensions/chassis/readonly-command.ts +440 -0
- package/extensions/chassis/sanitize.ts +108 -0
- package/extensions/chassis/session-info.ts +127 -0
- package/extensions/chassis/sidebar.ts +285 -0
- package/extensions/chassis/state.ts +50 -0
- package/extensions/chassis/telemetry.ts +222 -0
- package/extensions/chassis/tools.ts +618 -0
- package/extensions/chassis/version.ts +20 -0
- package/extensions/diff/git-diff.ts +365 -0
- package/extensions/diff/host.ts +58 -0
- package/extensions/diff/index.ts +101 -0
- package/extensions/diff/render.ts +217 -0
- package/extensions/diff/types.ts +67 -0
- package/extensions/diff/view.ts +523 -0
- package/extensions/git/flows.ts +485 -0
- package/extensions/git/forge.ts +54 -0
- package/extensions/git/index.ts +117 -0
- package/extensions/git/repo.ts +200 -0
- package/extensions/git/run.ts +143 -0
- package/extensions/git/settings.ts +38 -0
- package/extensions/git/text.ts +133 -0
- package/extensions/widgets/container/detect.ts +81 -0
- package/extensions/widgets/container/index.ts +29 -0
- package/extensions/widgets/mcp/index.ts +53 -0
- package/extensions/widgets/mcp/status.ts +94 -0
- package/extensions/widgets/session-todos/index.ts +55 -0
- package/extensions/widgets/subagents/index.ts +170 -0
- package/extensions/widgets/subagents/status.ts +237 -0
- package/extensions/widgets/todo/index.ts +66 -0
- package/extensions/widgets/todo/parse.ts +119 -0
- package/package.json +91 -4
- package/scripts/link-pi.mjs +76 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider status: how a model provider adds its own facts to the sidebar's provider section and
|
|
3
|
+
* the footer, such as how much of a plan or quota has been used.
|
|
4
|
+
*
|
|
5
|
+
* Without a registration the section shows the provider's name, the model, context use, tokens and
|
|
6
|
+
* cost, all of which pi reports for every provider. A `<provider>-usage` status that carries a
|
|
7
|
+
* percentage also becomes a Plan bar with no registration (provider-view.ts, activeProviderStatus). A provider extension (or any other package)
|
|
8
|
+
* adds more by registering a spec from client.ts:
|
|
9
|
+
*
|
|
10
|
+
* import { registerProviderStatus } from "pi-chassis/client";
|
|
11
|
+
*
|
|
12
|
+
* // The provider already publishes a percentage with ctx.ui.setStatus("acme-usage", "Acme 71%"):
|
|
13
|
+
* registerProviderStatus({ provider: "acme", name: "Acme", color: "plan", statusKey: "acme-usage", label: "Plan" });
|
|
14
|
+
*
|
|
15
|
+
* // Or compute meters from anything:
|
|
16
|
+
* registerProviderStatus({
|
|
17
|
+
* provider: "acme",
|
|
18
|
+
* meters: (ctx) => [{ label: "Quota", percent: readQuota(ctx), detail: "5 of 20 requests" }],
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* The registry lives on globalThis (see brand.ts), so load order doesn't matter and a later
|
|
22
|
+
* registration for the same provider replaces the earlier one.
|
|
23
|
+
*
|
|
24
|
+
* This file has no value imports, so client.ts can re-export it without loading anything else.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import type { WidgetColor } from "./api.ts";
|
|
29
|
+
|
|
30
|
+
/** One bar in the provider section. */
|
|
31
|
+
export interface ProviderMeter {
|
|
32
|
+
/** Up to 9 columns, e.g. "Plan". */
|
|
33
|
+
label: string;
|
|
34
|
+
/** 0 to 100. Undefined shows a dash. */
|
|
35
|
+
percent?: number;
|
|
36
|
+
/** Dim line under the bar, e.g. "5 of 20 requests". */
|
|
37
|
+
detail?: string;
|
|
38
|
+
/** Bar colour. Default: accent, warning from 60%, error from 85%. */
|
|
39
|
+
color?: WidgetColor;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProviderStatusSpec {
|
|
43
|
+
/** The `model.provider` id (or ids) this applies to. */
|
|
44
|
+
provider: string | string[];
|
|
45
|
+
/** Display name for the section heading. Default: the name the provider registered with pi, else the id with a capital letter. */
|
|
46
|
+
name?: string;
|
|
47
|
+
/** Colour of the heading glyph. Default: accent. */
|
|
48
|
+
color?: WidgetColor;
|
|
49
|
+
/**
|
|
50
|
+
* A `setStatus()` key whose text carries a percentage, for a provider that already publishes
|
|
51
|
+
* its usage that way. The first number in the text (after any leading glyph and the provider
|
|
52
|
+
* name) becomes a bar, painted in the status's own colour if it has one. The status is then
|
|
53
|
+
* left out of the generic status lists, and shown next to the context in the footer when the
|
|
54
|
+
* sidebar is hidden.
|
|
55
|
+
*/
|
|
56
|
+
statusKey?: string;
|
|
57
|
+
/** Bar label for `statusKey`. Default "Plan". */
|
|
58
|
+
label?: string;
|
|
59
|
+
/** Extra bars computed from the session. Called on every repaint, so keep it cheap. */
|
|
60
|
+
meters?: (ctx: ExtensionContext) => ProviderMeter[] | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const KEY = Symbol.for("pi-chassis/providers");
|
|
64
|
+
|
|
65
|
+
interface Store {
|
|
66
|
+
specs: ProviderStatusSpec[];
|
|
67
|
+
version: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function store(): Store {
|
|
71
|
+
const g = globalThis as unknown as Record<symbol, Store | undefined>;
|
|
72
|
+
g[KEY] ??= { specs: [], version: 0 };
|
|
73
|
+
return g[KEY]!;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const idsOf = (s: ProviderStatusSpec): string[] => (Array.isArray(s.provider) ? s.provider : [s.provider]);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Register (or replace) the status spec for one or more providers. Returns a function that removes
|
|
80
|
+
* it. The ids of a new spec replace the same ids in older specs.
|
|
81
|
+
*/
|
|
82
|
+
export function setProviderStatus(spec: ProviderStatusSpec): () => void {
|
|
83
|
+
const s = store();
|
|
84
|
+
const ids = new Set(idsOf(spec));
|
|
85
|
+
s.specs = s.specs.filter((old) => !idsOf(old).every((id) => ids.has(id)));
|
|
86
|
+
s.specs.push(spec);
|
|
87
|
+
s.version++;
|
|
88
|
+
return () => {
|
|
89
|
+
const i = s.specs.indexOf(spec);
|
|
90
|
+
if (i >= 0) s.specs.splice(i, 1);
|
|
91
|
+
s.version++;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The spec for a provider id, if one is registered. */
|
|
96
|
+
export function getProviderStatus(provider: string | undefined): ProviderStatusSpec | undefined {
|
|
97
|
+
if (!provider) return undefined;
|
|
98
|
+
const specs = store().specs;
|
|
99
|
+
for (let i = specs.length - 1; i >= 0; i--) if (idsOf(specs[i]!).includes(provider)) return specs[i];
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `setStatus()` keys that registered specs consume, so the generic status lists skip them. */
|
|
104
|
+
export function providerStatusKeys(): Set<string> {
|
|
105
|
+
const keys = new Set<string>();
|
|
106
|
+
for (const s of store().specs) if (s.statusKey) keys.add(s.statusKey);
|
|
107
|
+
return keys;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Forget every registration (tests). */
|
|
111
|
+
export function resetProviderStatus(): void {
|
|
112
|
+
const s = store();
|
|
113
|
+
s.specs = [];
|
|
114
|
+
s.version++;
|
|
115
|
+
}
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bash filter behind Plan mode: decides whether a command line looks read-only.
|
|
3
|
+
*
|
|
4
|
+
* It is a strict allow-list, not a deny-list. The line is split into words the way bash would
|
|
5
|
+
* (quotes, escapes, operators), anything that can hide a second command or a write is rejected
|
|
6
|
+
* outright (command substitution, subshells, heredocs, redirects other than to /dev/null), and
|
|
7
|
+
* every pipeline segment must then start with a command on the list below, with the flags that let
|
|
8
|
+
* that command write or run other programs rejected. When unsure it blocks.
|
|
9
|
+
*
|
|
10
|
+
* This is a guardrail against a model wandering off, not a sandbox. Build and test commands
|
|
11
|
+
* (cargo check, pnpm run test) run project code and write caches or build output, and the
|
|
12
|
+
* filter accepts that on purpose.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Raised while parsing when the line contains something the filter will not reason about. */
|
|
16
|
+
class Rejection extends Error {}
|
|
17
|
+
|
|
18
|
+
const SUBSTITUTION = "command substitution and subshells are not allowed";
|
|
19
|
+
const EXPANSION = "unquoted expansion ($VAR, {a,b}, *, ?, [..], ~) is not allowed: quote the word";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Splits a command line into pipeline segments of words, with quotes and escapes resolved.
|
|
23
|
+
* Redirects are validated and dropped; throws Rejection for anything that is not plain.
|
|
24
|
+
*/
|
|
25
|
+
function parseSegments(command: string): string[][] {
|
|
26
|
+
if (/[\n\r]/.test(command)) throw new Rejection("newlines and heredocs are not allowed");
|
|
27
|
+
const segments: string[][] = [];
|
|
28
|
+
let words: string[] = [];
|
|
29
|
+
let word = "";
|
|
30
|
+
let inWord = false;
|
|
31
|
+
let quoted = false;
|
|
32
|
+
let redirect: "out" | "dup" | "in" | undefined;
|
|
33
|
+
let i = 0;
|
|
34
|
+
|
|
35
|
+
const endWord = () => {
|
|
36
|
+
if (!inWord) return;
|
|
37
|
+
if (redirect) checkRedirectTarget(redirect, word);
|
|
38
|
+
else words.push(word);
|
|
39
|
+
redirect = undefined;
|
|
40
|
+
word = "";
|
|
41
|
+
inWord = false;
|
|
42
|
+
quoted = false;
|
|
43
|
+
};
|
|
44
|
+
/** Closes the current segment. An operator needs a command before it, and a pipe or && or || one after it too. */
|
|
45
|
+
let needsCommand = false;
|
|
46
|
+
const endSegment = (operator?: string) => {
|
|
47
|
+
endWord();
|
|
48
|
+
if (redirect) throw new Rejection("a redirect has no target");
|
|
49
|
+
if (words.length) segments.push(words);
|
|
50
|
+
else if (operator || needsCommand) throw new Rejection("an operator has no command next to it");
|
|
51
|
+
words = [];
|
|
52
|
+
needsCommand = operator === "|" || operator === "&&" || operator === "||";
|
|
53
|
+
};
|
|
54
|
+
/** A leading number is a file descriptor ("2>"), not an argument. */
|
|
55
|
+
const startRedirect = () => {
|
|
56
|
+
if (inWord && !quoted && /^\d+$/.test(word)) {
|
|
57
|
+
word = "";
|
|
58
|
+
inWord = false;
|
|
59
|
+
} else endWord();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
scan: while (i < command.length) {
|
|
63
|
+
const c = command[i];
|
|
64
|
+
switch (c) {
|
|
65
|
+
case " ":
|
|
66
|
+
case "\t":
|
|
67
|
+
endWord();
|
|
68
|
+
i++;
|
|
69
|
+
break;
|
|
70
|
+
case "'": {
|
|
71
|
+
const end = command.indexOf("'", i + 1);
|
|
72
|
+
if (end < 0) throw new Rejection("unterminated quote");
|
|
73
|
+
word += command.slice(i + 1, end);
|
|
74
|
+
inWord = quoted = true;
|
|
75
|
+
i = end + 1;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case '"': {
|
|
79
|
+
i++;
|
|
80
|
+
inWord = quoted = true;
|
|
81
|
+
for (;;) {
|
|
82
|
+
const d = command[i];
|
|
83
|
+
if (d === undefined) throw new Rejection("unterminated quote");
|
|
84
|
+
if (d === '"') {
|
|
85
|
+
i++;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
if (d === "\\") {
|
|
89
|
+
const e = command[i + 1];
|
|
90
|
+
if (e === undefined) throw new Rejection("unterminated quote");
|
|
91
|
+
word += '$`"\\'.includes(e) ? e : d + e;
|
|
92
|
+
i += 2;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (d === "`" || (d === "$" && (command[i + 1] === "(" || command[i + 1] === "{"))) throw new Rejection(SUBSTITUTION);
|
|
96
|
+
if (d === "$" && command[i + 1] !== undefined && /[A-Za-z0-9_@*#?$!-]/.test(command[i + 1]!)) throw new Rejection(EXPANSION);
|
|
97
|
+
word += d;
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case "\\": {
|
|
103
|
+
const e = command[i + 1];
|
|
104
|
+
if (e === undefined) throw new Rejection("a trailing backslash is not allowed");
|
|
105
|
+
word += e;
|
|
106
|
+
inWord = quoted = true;
|
|
107
|
+
i += 2;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case "`":
|
|
111
|
+
case "(":
|
|
112
|
+
case ")":
|
|
113
|
+
throw new Rejection(SUBSTITUTION);
|
|
114
|
+
case "$": {
|
|
115
|
+
const next = command[i + 1];
|
|
116
|
+
if (next === "(" || next === "{") throw new Rejection(SUBSTITUTION);
|
|
117
|
+
if (next === "'" || next === '"') throw new Rejection("$'...' and $\"...\" quoting is not allowed");
|
|
118
|
+
// $NAME, $1, $@ and the like expand before the command sees them, so a checked word
|
|
119
|
+
// could still turn into "-o file" ($IFS splits words). Only a lone $ is literal.
|
|
120
|
+
if (next !== undefined && /[A-Za-z0-9_@*#?$!-]/.test(next)) throw new Rejection(EXPANSION);
|
|
121
|
+
word += c;
|
|
122
|
+
inWord = true;
|
|
123
|
+
i++;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case "#":
|
|
127
|
+
if (inWord) {
|
|
128
|
+
word += c;
|
|
129
|
+
i++;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
break scan; // a comment runs to the end of the line
|
|
133
|
+
case ";":
|
|
134
|
+
endSegment(";");
|
|
135
|
+
i++;
|
|
136
|
+
break;
|
|
137
|
+
case "|":
|
|
138
|
+
if (command[i + 1] === "|") {
|
|
139
|
+
endSegment("||");
|
|
140
|
+
i += 2;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
endSegment("|");
|
|
144
|
+
i++;
|
|
145
|
+
if (command[i] === "&") i++; // |&
|
|
146
|
+
break;
|
|
147
|
+
case "&":
|
|
148
|
+
if (command[i + 1] === ">") {
|
|
149
|
+
endWord();
|
|
150
|
+
i++; // &> and &>> redirect like >
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
if (command[i + 1] === "&") {
|
|
154
|
+
endSegment("&&");
|
|
155
|
+
i += 2;
|
|
156
|
+
} else {
|
|
157
|
+
endSegment("&");
|
|
158
|
+
i++;
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
case ">": {
|
|
162
|
+
startRedirect();
|
|
163
|
+
if (redirect) throw new Rejection("a redirect has no target");
|
|
164
|
+
i++;
|
|
165
|
+
let kind: "out" | "dup" = "out";
|
|
166
|
+
if (command[i] === ">" || command[i] === "|") i++;
|
|
167
|
+
else if (command[i] === "&") {
|
|
168
|
+
kind = "dup";
|
|
169
|
+
i++;
|
|
170
|
+
}
|
|
171
|
+
redirect = kind;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case "<": {
|
|
175
|
+
const next = command[i + 1];
|
|
176
|
+
if (next === "<") throw new Rejection("heredocs and here-strings are not allowed");
|
|
177
|
+
if (next === ">") throw new Rejection("read-write redirects are not allowed");
|
|
178
|
+
startRedirect();
|
|
179
|
+
if (redirect) throw new Rejection("a redirect has no target");
|
|
180
|
+
i++;
|
|
181
|
+
if (command[i] === "&") {
|
|
182
|
+
redirect = "dup";
|
|
183
|
+
i++;
|
|
184
|
+
} else redirect = "in";
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "{":
|
|
188
|
+
case "}":
|
|
189
|
+
case "*":
|
|
190
|
+
case "?":
|
|
191
|
+
case "[":
|
|
192
|
+
// Brace expansion and globbing happen after this check, so "{--output=f,}" or
|
|
193
|
+
// "-[o]" would reach the command as an option this filter never saw. Quote them.
|
|
194
|
+
throw new Rejection(EXPANSION);
|
|
195
|
+
case "~":
|
|
196
|
+
if (!inWord) throw new Rejection(EXPANSION);
|
|
197
|
+
word += c;
|
|
198
|
+
i++;
|
|
199
|
+
break;
|
|
200
|
+
default:
|
|
201
|
+
word += c;
|
|
202
|
+
inWord = true;
|
|
203
|
+
i++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
endSegment();
|
|
207
|
+
if (!segments.length) throw new Rejection("empty command");
|
|
208
|
+
return segments;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Output may only go to /dev/null or duplicate another descriptor ("2>&1"). Input may come from
|
|
213
|
+
* any file except bash's /dev/tcp and /dev/udp, which open network connections.
|
|
214
|
+
*/
|
|
215
|
+
function checkRedirectTarget(kind: "out" | "dup" | "in", target: string): void {
|
|
216
|
+
if (kind === "in") {
|
|
217
|
+
if (/^\/dev\/(tcp|udp)\//.test(target)) throw new Rejection("network redirects are not allowed");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (target === "/dev/null") return;
|
|
221
|
+
if (kind === "dup" && /^(\d+-?|-)$/.test(target)) return;
|
|
222
|
+
throw new Rejection(`redirecting output to ${target} is not allowed (only /dev/null)`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The name of a long option ("--output=f" gives "output"), or undefined for anything else. */
|
|
226
|
+
function longName(arg: string): string | undefined {
|
|
227
|
+
return arg.startsWith("--") && arg.length > 2 ? arg.slice(2).split("=")[0] : undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** True if arg is a long option that is `full` or an abbreviation of it (GNU getopt and git accept those). */
|
|
231
|
+
function abbreviates(arg: string, full: string): boolean {
|
|
232
|
+
const name = longName(arg);
|
|
233
|
+
return name !== undefined && full.startsWith(name);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** True for a short-option cluster such as "-lao" that contains the letter. */
|
|
237
|
+
function hasShort(arg: string, letters: string): boolean {
|
|
238
|
+
return new RegExp(`^-[^-]*[${letters}]`).test(arg);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
type Check = (args: string[]) => string | undefined;
|
|
242
|
+
|
|
243
|
+
const allowAll: Check = () => undefined;
|
|
244
|
+
|
|
245
|
+
/** Builds a check that rejects the first argument the predicate flags, naming it. */
|
|
246
|
+
function rejectArgs(isDangerous: (arg: string) => boolean, why: string): Check {
|
|
247
|
+
return (args) => {
|
|
248
|
+
const bad = args.find(isDangerous);
|
|
249
|
+
return bad === undefined ? undefined : `${bad} ${why}`;
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const FIND_UNSAFE = new Set(["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"]);
|
|
254
|
+
|
|
255
|
+
const SED_ADDRESS = String.raw`(\d+|\$|/[^/\\]*/)`;
|
|
256
|
+
const SED_PRINT = new RegExp(`^(${SED_ADDRESS}(,${SED_ADDRESS})?)?p$`);
|
|
257
|
+
|
|
258
|
+
const checkSed: Check = (args) => {
|
|
259
|
+
let script: string | undefined;
|
|
260
|
+
for (const a of args) {
|
|
261
|
+
if (a === "-n" || a === "--quiet" || a === "--silent") continue;
|
|
262
|
+
if (a.startsWith("-")) return `sed ${a} is not allowed`;
|
|
263
|
+
script ??= a;
|
|
264
|
+
}
|
|
265
|
+
// A sed script can write files (w) and run commands (e), so only printing an address range is allowed.
|
|
266
|
+
if (script === undefined || !SED_PRINT.test(script)) return "sed is only allowed for printing lines (sed -n 5,10p file)";
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** `uniq in out` writes to its second file. The values of -f, -s and -w are not files. */
|
|
270
|
+
const checkUniq: Check = (args) => {
|
|
271
|
+
const files = args.filter((a, i) => !a.startsWith("-") && !["-f", "-s", "-w"].includes(args[i - 1]));
|
|
272
|
+
return files.length > 1 ? "uniq with an output file is not allowed" : undefined;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const checkEnv: Check = (args) => (args.every((a) => a === "-0" || a === "--null") ? undefined : "env is only allowed without a command");
|
|
276
|
+
|
|
277
|
+
const GIT_READ = new Set([
|
|
278
|
+
"status", "log", "diff", "show", "rev-parse", "ls-files", "blame", "describe", "rev-list", "shortlog", "ls-tree", "cat-file",
|
|
279
|
+
"grep", "merge-base", "name-rev", "for-each-ref", "show-ref",
|
|
280
|
+
]);
|
|
281
|
+
const GIT_LEADING_FLAGS = new Set(["--no-pager", "--no-optional-locks"]);
|
|
282
|
+
|
|
283
|
+
interface ListingForms {
|
|
284
|
+
flags: string[];
|
|
285
|
+
valued: string[];
|
|
286
|
+
/** Flags that put the command in list mode, where positional words are patterns and not new names. */
|
|
287
|
+
listing: string[];
|
|
288
|
+
}
|
|
289
|
+
const BRANCH_FORMS: ListingForms = {
|
|
290
|
+
flags: ["-a", "--all", "-r", "--remotes", "-v", "--verbose", "-i", "--ignore-case", "--show-current", "--no-abbrev", "--no-color", "--no-column"],
|
|
291
|
+
valued: ["--sort=", "--format=", "--abbrev=", "--color=", "--column="],
|
|
292
|
+
listing: ["-l", "--list", "--contains", "--no-contains", "--merged", "--no-merged", "--points-at"],
|
|
293
|
+
};
|
|
294
|
+
const TAG_FORMS: ListingForms = {
|
|
295
|
+
flags: ["-i", "--ignore-case", "--no-color", "--no-column"],
|
|
296
|
+
valued: ["--sort=", "--format=", "--color=", "--column="],
|
|
297
|
+
listing: ["-l", "--list", "-n", "--contains", "--no-contains", "--merged", "--no-merged", "--points-at"],
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/** git branch and git tag create, move and delete refs unless they are only listing. */
|
|
301
|
+
function listingOnly(name: string, args: string[], forms: ListingForms): string | undefined {
|
|
302
|
+
let listing = false;
|
|
303
|
+
let positional = false;
|
|
304
|
+
for (const a of args) {
|
|
305
|
+
if (!a.startsWith("-")) {
|
|
306
|
+
positional = true;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const flag = a.replace(/^-n\d+$/, "-n").replace(/^-[arv]{2,}$/, "-v");
|
|
310
|
+
if (forms.listing.includes(flag)) listing = true;
|
|
311
|
+
else if (!forms.flags.includes(flag) && !forms.valued.some((v) => flag.startsWith(v))) return `git ${name} ${a} is not allowed (listing only)`;
|
|
312
|
+
}
|
|
313
|
+
return positional && !listing ? `git ${name} with a name would create or change a ref (listing only: use --list)` : undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const checkGit: Check = (args) => {
|
|
317
|
+
let i = 0;
|
|
318
|
+
while (i < args.length && args[i].startsWith("-")) {
|
|
319
|
+
if (GIT_LEADING_FLAGS.has(args[i])) i++;
|
|
320
|
+
else if (args[i] === "-C") i += 2;
|
|
321
|
+
else return `git ${args[i]} before the subcommand is not allowed`;
|
|
322
|
+
}
|
|
323
|
+
const sub = args[i];
|
|
324
|
+
if (sub === undefined) return "git needs a read-only subcommand";
|
|
325
|
+
const rest = args.slice(i + 1);
|
|
326
|
+
if (GIT_READ.has(sub)) {
|
|
327
|
+
const bad = rest.find(
|
|
328
|
+
(a) =>
|
|
329
|
+
abbreviates(a, "output") ||
|
|
330
|
+
abbreviates(a, "ext-diff") ||
|
|
331
|
+
abbreviates(a, "textconv") ||
|
|
332
|
+
abbreviates(a, "no-index") ||
|
|
333
|
+
abbreviates(a, "open-files-in-pager") ||
|
|
334
|
+
(sub === "grep" && hasShort(a, "O")),
|
|
335
|
+
);
|
|
336
|
+
return bad === undefined ? undefined : `git ${sub} ${bad} can write files or run programs`;
|
|
337
|
+
}
|
|
338
|
+
switch (sub) {
|
|
339
|
+
case "branch":
|
|
340
|
+
return listingOnly("branch", rest, BRANCH_FORMS);
|
|
341
|
+
case "tag":
|
|
342
|
+
return listingOnly("tag", rest, TAG_FORMS);
|
|
343
|
+
case "remote":
|
|
344
|
+
if (rest.every((a) => a === "-v" || a === "--verbose")) return undefined;
|
|
345
|
+
// "remote show" contacts the remote, so only the offline forms are allowed.
|
|
346
|
+
if (rest[0] === "get-url" && rest.slice(1).every((a) => !a.startsWith("-") || a === "--push" || a === "--all")) return undefined;
|
|
347
|
+
return "git remote is only allowed for listing (git remote -v, git remote get-url)";
|
|
348
|
+
case "config": {
|
|
349
|
+
const readers = ["--get", "--get-all", "--get-regexp", "--list", "-l"];
|
|
350
|
+
const scopes = ["--local", "--global", "--system", "--worktree", "--show-origin", "--show-scope", "--name-only", "-z", "--null", "--includes", "--no-includes"];
|
|
351
|
+
const flags = rest.filter((a) => a.startsWith("-"));
|
|
352
|
+
if (!flags.some((a) => readers.includes(a))) return "git config is only allowed with --get or --list";
|
|
353
|
+
const bad = flags.find((a) => !readers.includes(a) && !scopes.includes(a));
|
|
354
|
+
return bad === undefined ? undefined : `git config ${bad} is not allowed`;
|
|
355
|
+
}
|
|
356
|
+
default:
|
|
357
|
+
return `git ${sub} is not on the read-only list`;
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const SCRIPT_SHELL_FLAGS = ["script-shell", "shell-emulator"];
|
|
362
|
+
|
|
363
|
+
/** npm, pnpm and yarn: inspection commands, plus the lint, typecheck and test scripts. */
|
|
364
|
+
const checkPackageManager: Check = (args) => {
|
|
365
|
+
const sub = args[0];
|
|
366
|
+
if (["list", "ls", "view", "info", "why", "outdated"].includes(sub)) return undefined;
|
|
367
|
+
if (sub === "test" || (sub === "run" && ["lint", "typecheck", "test"].includes(args[1]))) {
|
|
368
|
+
const bad = args.slice(1).find((a) => SCRIPT_SHELL_FLAGS.some((f) => abbreviates(a, f)));
|
|
369
|
+
return bad === undefined ? undefined : `${bad} could run another program`;
|
|
370
|
+
}
|
|
371
|
+
return "only list, view, info, why, outdated, test and run lint, typecheck or test are allowed";
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const checkCargo: Check = (args) => {
|
|
375
|
+
if (args[0] !== "check" && args[0] !== "test") return "only cargo check and cargo test are allowed";
|
|
376
|
+
const bad = args.find((a) => a === "--config" || a.startsWith("--config=") || /^-Z/.test(a));
|
|
377
|
+
return bad === undefined ? undefined : `cargo ${bad} can run other programs`;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const GO_UNSAFE_FLAG = /^-{1,2}(exec|toolexec|vettool|c|o|outputdir|trace|fuzz|[a-z.]*profile)(=|$)/;
|
|
381
|
+
const checkGo: Check = (args) => {
|
|
382
|
+
if (args[0] !== "vet" && args[0] !== "test") return "only go vet and go test are allowed";
|
|
383
|
+
const bad = args.find((a) => GO_UNSAFE_FLAG.test(a));
|
|
384
|
+
return bad === undefined ? undefined : `go ${bad} can write files or run other programs`;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const versionOnly = (...flags: string[]): Check => (args) => (args.length === 1 && flags.includes(args[0]) ? undefined : "only the version flag is allowed");
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The commands that may start a pipeline segment. Left out on purpose: awk (system(), getline and
|
|
391
|
+
* print > file), yq (in-place edit and split output), less and more (shell escapes), xargs, and anything else that
|
|
392
|
+
* takes a script or a command to run.
|
|
393
|
+
*/
|
|
394
|
+
const COMMANDS = new Map<string, Check>();
|
|
395
|
+
for (const name of ["cd", "cat", "head", "tail", "ls", "pwd", "echo", "printf", "wc", "grep", "diff", "stat", "du", "df", "which", "whereis", "type", "uname", "whoami", "id", "uptime", "ps", "free", "jq", "cut", "tr", "basename", "dirname", "realpath", "readlink", "printenv"]) {
|
|
396
|
+
COMMANDS.set(name, allowAll);
|
|
397
|
+
}
|
|
398
|
+
COMMANDS.set("find", rejectArgs((a) => FIND_UNSAFE.has(a), "can delete files or run commands"));
|
|
399
|
+
COMMANDS.set("fd", rejectArgs((a) => hasShort(a, "xX") || a.startsWith("--exec"), "can run commands"));
|
|
400
|
+
COMMANDS.set("rg", rejectArgs((a) => a === "--pre" || a.startsWith("--pre=") || a === "--hostname-bin" || a.startsWith("--hostname-bin="), "runs another program"));
|
|
401
|
+
COMMANDS.set("sort", rejectArgs((a) => hasShort(a, "o") || abbreviates(a, "output") || abbreviates(a, "compress-program"), "can write files or run programs"));
|
|
402
|
+
COMMANDS.set("uniq", checkUniq);
|
|
403
|
+
COMMANDS.set("file", rejectArgs((a) => hasShort(a, "C") || abbreviates(a, "compile"), "writes a file"));
|
|
404
|
+
COMMANDS.set("date", rejectArgs((a) => hasShort(a, "s") || abbreviates(a, "set"), "sets the clock"));
|
|
405
|
+
COMMANDS.set("tree", rejectArgs((a) => hasShort(a, "o") || a.startsWith("--o"), "writes a file"));
|
|
406
|
+
COMMANDS.set("env", checkEnv);
|
|
407
|
+
COMMANDS.set("sed", checkSed);
|
|
408
|
+
COMMANDS.set("git", checkGit);
|
|
409
|
+
for (const name of ["npm", "pnpm", "yarn"]) COMMANDS.set(name, checkPackageManager);
|
|
410
|
+
COMMANDS.set("node", versionOnly("--version", "-v"));
|
|
411
|
+
COMMANDS.set("python", versionOnly("--version", "-V"));
|
|
412
|
+
COMMANDS.set("python3", versionOnly("--version", "-V"));
|
|
413
|
+
COMMANDS.set("uv", versionOnly("--version", "-V"));
|
|
414
|
+
COMMANDS.set("cargo", checkCargo);
|
|
415
|
+
COMMANDS.set("go", checkGo);
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Why a command line is not allowed in Plan mode, or undefined if every part of it looks read-only.
|
|
419
|
+
*/
|
|
420
|
+
export function explainBlockedCommand(command: string): string | undefined {
|
|
421
|
+
let segments: string[][];
|
|
422
|
+
try {
|
|
423
|
+
segments = parseSegments(command);
|
|
424
|
+
} catch (e) {
|
|
425
|
+
if (e instanceof Rejection) return e.message;
|
|
426
|
+
throw e;
|
|
427
|
+
}
|
|
428
|
+
for (const [name, ...args] of segments) {
|
|
429
|
+
const check = COMMANDS.get(name);
|
|
430
|
+
if (!check) return `${name} is not on the read-only list`;
|
|
431
|
+
const why = check(args);
|
|
432
|
+
if (why) return why;
|
|
433
|
+
}
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** True if every pipeline/sequence segment of a command looks read-only. */
|
|
438
|
+
export function isReadOnlyCommand(command: string): boolean {
|
|
439
|
+
return explainBlockedCommand(command) === undefined;
|
|
440
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleaning for text that did not come from chassis: tool arguments and results, widget values, session
|
|
3
|
+
* names, directory names. A terminal acts on escape sequences in anything it is sent, so text that
|
|
4
|
+
* can be influenced from outside (a file's contents, a branch name, a model's tool call) must not
|
|
5
|
+
* reach it as it is: an OSC 52 sequence writes to the clipboard, OSC 0 and OSC 2 set the window
|
|
6
|
+
* title, OSC 8 draws a link, a cursor-move CSI overdraws other rows, and bidi overrides reorder
|
|
7
|
+
* what the reader sees.
|
|
8
|
+
*
|
|
9
|
+
* No imports, so every module (api.ts included) can use it without an import cycle. palette.ts
|
|
10
|
+
* re-exports it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** CSI: ESC [ or the 8-bit introducer, parameter bytes, intermediate bytes, one final byte. */
|
|
14
|
+
const CSI = /(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g;
|
|
15
|
+
/** OSC: titles, hyperlinks, clipboard. Ends at BEL, ST or the end of the text. */
|
|
16
|
+
const OSC = /(?:\x1b\]|\x9d)[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)?/g;
|
|
17
|
+
/** DCS, SOS, PM and APC strings, which run to ST. */
|
|
18
|
+
const STRING_SEQUENCE = /(?:\x1b[PX^_]|[\x90\x98\x9e\x9f])[^\x1b\x9c]*(?:\x1b\\|\x9c)?/g;
|
|
19
|
+
/** Any other ESC sequence: ESC, intermediate bytes, one final byte (or a bare ESC). */
|
|
20
|
+
const OTHER_ESCAPE = /\x1b[ -/]*[0-~]?/g;
|
|
21
|
+
/** Directional marks, embeddings and overrides, and isolates. */
|
|
22
|
+
const BIDI = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
|
|
23
|
+
/** C0 and C1 controls, DEL, and the Unicode line and paragraph separators. */
|
|
24
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
|
|
25
|
+
/** C0 and C1 controls except tab and newline. */
|
|
26
|
+
const CONTROL_KEEP_BREAKS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
|
27
|
+
|
|
28
|
+
/** Stand-ins for SGR sequences kept while the rest is scrubbed (private-use, never in real text). */
|
|
29
|
+
const MARK_OPEN = "\uFDD0";
|
|
30
|
+
const MARK_CLOSE = "\uFDD1";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `text` without escape sequences (CSI, OSC, DCS, APC and the rest) and without bidi controls.
|
|
34
|
+
* Other characters, newlines and tabs included, stay. A stray ESC left where removing one sequence
|
|
35
|
+
* joined the pieces of another is for the caller's control-character pass to remove.
|
|
36
|
+
*/
|
|
37
|
+
export function stripEscapes(text: string): string {
|
|
38
|
+
return text.replace(CSI, "").replace(OSC, "").replace(STRING_SEQUENCE, "").replace(OTHER_ESCAPE, "").replace(BIDI, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const tidy = (s: string) => s.replace(/ {2,}/g, " ").trim();
|
|
42
|
+
|
|
43
|
+
function scrub(text: unknown, keepColor: boolean, finish: (s: string) => string): string {
|
|
44
|
+
const sgr: string[] = [];
|
|
45
|
+
let s = String(text ?? "");
|
|
46
|
+
if (keepColor) {
|
|
47
|
+
s = s.replace(/[\uFDD0\uFDD1]/g, "").replace(/\x1b\[[0-9;:]*m/g, (m) => `${MARK_OPEN}${sgr.push(m) - 1}${MARK_CLOSE}`);
|
|
48
|
+
}
|
|
49
|
+
s = finish(stripEscapes(s).replace(CONTROL, " "));
|
|
50
|
+
if (sgr.length === 0) return s;
|
|
51
|
+
return `${s.replace(/\uFDD0(\d+)\uFDD1/g, (_m, i: string) => sgr[Number(i)] ?? "")}\x1b[0m`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One line of plain text: escape sequences and bidi controls removed, every control character
|
|
56
|
+
* (newline and tab included) turned into a space, runs of spaces collapsed, ends trimmed.
|
|
57
|
+
*/
|
|
58
|
+
export function sanitize(text: unknown): string {
|
|
59
|
+
return scrub(text, false, tidy);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Like `sanitize`, but SGR colour and style sequences are kept (and followed by a reset). */
|
|
63
|
+
export function sanitizeKeepColor(text: unknown): string {
|
|
64
|
+
return scrub(text, true, tidy);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One line like `sanitize`, but spacing is left alone: for text where the spaces carry meaning
|
|
69
|
+
* (aligned columns, indentation, a directory name). SGR sequences are kept with `keepColor`.
|
|
70
|
+
*/
|
|
71
|
+
export function scrubText(text: unknown, keepColor = false): string {
|
|
72
|
+
return scrub(text, keepColor, (s) => s);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Several lines of plain text: escape sequences, bidi controls and control characters removed,
|
|
77
|
+
* carriage returns and line separators turned into line breaks. Tabs and line breaks stay, so
|
|
78
|
+
* callers split on "\n" and decide what to do with a tab.
|
|
79
|
+
*/
|
|
80
|
+
export function sanitizeLines(text: unknown): string {
|
|
81
|
+
return stripEscapes(String(text ?? ""))
|
|
82
|
+
.replace(/\r\n?|[\u2028\u2029]/g, "\n")
|
|
83
|
+
.replace(CONTROL_KEEP_BREAKS, "");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Longest link target kept. Longer ones are refused rather than cut, since a cut URL goes elsewhere. */
|
|
87
|
+
const MAX_LINK_URL = 2048;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The URL to put in a link, or undefined if it is not safe to. Only a plain https URL passes: a host,
|
|
91
|
+
* no user name or password, no whitespace, control character, bidi control or backslash anywhere in
|
|
92
|
+
* it, at most 2048 characters. It comes back in the form `URL` writes it (scheme and host in lower
|
|
93
|
+
* case), so what is checked is what is sent.
|
|
94
|
+
*/
|
|
95
|
+
export function safeLinkUrl(url: unknown): string | undefined {
|
|
96
|
+
if (typeof url !== "string" || url.length === 0 || url.length > MAX_LINK_URL) return undefined;
|
|
97
|
+
if (/[\u0000-\u0020\u007f-\u009f\\\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/.test(url)) return undefined;
|
|
98
|
+
if (!/^https:\/\/[^/?#]/i.test(url)) return undefined;
|
|
99
|
+
let parsed: URL;
|
|
100
|
+
try {
|
|
101
|
+
parsed = new URL(url);
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password || !parsed.hostname) return undefined;
|
|
106
|
+
const out = parsed.href;
|
|
107
|
+
return out.length <= MAX_LINK_URL && !/[\u0000-\u0020\u007f-\u009f]/.test(out) ? out : undefined;
|
|
108
|
+
}
|