@impeccable/reviews 0.1.5 → 0.1.7
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/README.md +21 -20
- package/dist/agent.js +9 -4
- package/dist/cloudflare-access.js +77 -47
- package/dist/cloudflare.js +325 -23
- package/dist/fence.js +939 -143
- package/dist/focus.js +5 -4
- package/dist/host-previews.js +107 -0
- package/dist/host-questions.js +90 -20
- package/dist/hosted.js +72 -21
- package/dist/http.js +2 -0
- package/dist/log.js +7 -1
- package/dist/login.js +45 -13
- package/dist/main.js +32 -17
- package/dist/netlify-password.js +8 -6
- package/dist/netlify.js +43 -14
- package/dist/pi-paths.js +69 -0
- package/dist/playbook/cases/cloudflare-previews.md +12 -4
- package/dist/playbook/cases/first-preview-failure.md +1 -1
- package/dist/playbook/cases/live-app-address.md +15 -0
- package/dist/playbook/cases/monorepo.md +1 -1
- package/dist/playbook/cases/netlify-previews.md +3 -1
- package/dist/playbook/cases/netlify-setup.md +8 -7
- package/dist/playbook/cases/preview-protection.md +2 -1
- package/dist/playbook/cases/pull-requests.md +4 -4
- package/dist/playbook/cases/static-site.md +2 -2
- package/dist/playbook/cases/vercel-previews.md +2 -0
- package/dist/playbook/cases/vercel-setup.md +9 -6
- package/dist/playbook.js +2 -2
- package/dist/preflight.js +36 -7
- package/dist/report.js +1 -0
- package/dist/sandbox.js +364 -50
- package/dist/scan.js +134 -0
- package/dist/sync.js +44 -7
- package/dist/tools.js +397 -88
- package/dist/ui/bridge.js +18 -4
- package/dist/ui/look.js +1 -1
- package/dist/ui/screens.js +5 -3
- package/dist/ui/sensitive.js +10 -0
- package/dist/vercel-bypass.js +12 -7
- package/dist/vercel.js +110 -17
- package/package.json +1 -1
package/dist/scan.js
CHANGED
|
@@ -36,6 +36,95 @@ function valueAt(text, from) {
|
|
|
36
36
|
}
|
|
37
37
|
/** A name that says its value is a secret, anywhere in it; `author` is not `auth`. */
|
|
38
38
|
const SECRET_NAME = /secret|token|passw(?:or)?d|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?key|authoriz|auth(?!or)|credential/i;
|
|
39
|
+
/**
|
|
40
|
+
* How far the last text read for curlCommand was read, and what it said there: whether the command going on is curl's, the quote open, and
|
|
41
|
+
* where the last argument curlArgument read ends, since a `-u` inside an argument is not an option.
|
|
42
|
+
*/
|
|
43
|
+
let command;
|
|
44
|
+
/**
|
|
45
|
+
* The command going on at `at`, as the shell reads it: whether it is curl's, and the quote open there. A `;`, `&`, `|` or a line's end starts
|
|
46
|
+
* another command (`curl … && docker run -u node:node`), except inside quotes or after a backslash, which continues the line. curl counts inside
|
|
47
|
+
* quotes too, as in `sh -c "curl …"` or a command the debug log or the transcript writes as a JSON string. The text is read once from its start
|
|
48
|
+
* however far apart curl and its arguments are: each call goes on from where the last one on the same text stopped.
|
|
49
|
+
*/
|
|
50
|
+
function curlCommand(text, at) {
|
|
51
|
+
let state = command;
|
|
52
|
+
// Within one search the matches only move on, so one at or before the last starts a new search, which reads the text afresh.
|
|
53
|
+
if (!state || state.text !== text || at <= state.at)
|
|
54
|
+
state = command = { text, at: 0, curl: false, quote: '', argumentEnd: 0 };
|
|
55
|
+
let { at: i, curl, quote } = state;
|
|
56
|
+
for (; i < at; i++) {
|
|
57
|
+
const c = text[i];
|
|
58
|
+
// A backslash escapes a quote, a separator, a line's end or another backslash; before a letter it changes nothing, so `\curl` is still curl.
|
|
59
|
+
if (c === '\\' && quote !== "'" && /["';&|\n\\]/.test(text[i + 1] ?? ''))
|
|
60
|
+
i++;
|
|
61
|
+
else if (quote)
|
|
62
|
+
quote = c === quote ? '' : quote;
|
|
63
|
+
else if (c === '"' || c === "'")
|
|
64
|
+
quote = c;
|
|
65
|
+
else if (';&|\n'.includes(c))
|
|
66
|
+
curl = false;
|
|
67
|
+
if (c === 'c' && text.startsWith('curl', i) && !/[\w.-]/.test(text[i - 1] ?? ' ') && !/[\w.-]/.test(text[i + 4] ?? ' '))
|
|
68
|
+
curl = true;
|
|
69
|
+
}
|
|
70
|
+
Object.assign(state, { at: i, curl, quote });
|
|
71
|
+
return state;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The word that starts at `from`, as the shell reads it, inside the quote `outer` open there: where it ends, and the password after its first
|
|
75
|
+
* colon (what it stands for, and where it starts and ends in the text), or no password when it has no colon. Quotes and backslashes are read as
|
|
76
|
+
* the shell reads them; inside an outer double quote (`sh -c "…"`, a JSON string) `\"` quotes for the command within. A word is read once.
|
|
77
|
+
*/
|
|
78
|
+
function curlArgument(text, from, outer) {
|
|
79
|
+
let quote = '';
|
|
80
|
+
let colon = false;
|
|
81
|
+
let value = '';
|
|
82
|
+
let start = -1;
|
|
83
|
+
let to = from;
|
|
84
|
+
let i = from;
|
|
85
|
+
while (i < text.length) {
|
|
86
|
+
const c = text[i];
|
|
87
|
+
// The character the text stands for here, none for a quote's mark, and how many characters of the text it takes.
|
|
88
|
+
let stands = c;
|
|
89
|
+
let width = 1;
|
|
90
|
+
if (quote) {
|
|
91
|
+
if (quote === '\\"' ? text.startsWith('\\"', i) : c === quote)
|
|
92
|
+
[stands, width, quote] = ['', quote.length, ''];
|
|
93
|
+
else if (c === outer)
|
|
94
|
+
break;
|
|
95
|
+
// Inside an outer double quote its backslashes come first, in a single-quoted word too: `'a:b\"c'` in a JSON string is `a:b"c`.
|
|
96
|
+
else if (c === '\\' && (quote !== "'" || outer === '"'))
|
|
97
|
+
[stands, width] = [text[i + 1] ?? '', 2];
|
|
98
|
+
}
|
|
99
|
+
else if (outer === '"' && text.startsWith('\\"', i)) {
|
|
100
|
+
[stands, width, quote] = ['', 2, '\\"'];
|
|
101
|
+
}
|
|
102
|
+
else if (c === '\\' && outer !== "'") {
|
|
103
|
+
[stands, width] = [text[i + 1] === '\n' ? '' : (text[i + 1] ?? ''), 2];
|
|
104
|
+
}
|
|
105
|
+
else if (c === outer || /\s/.test(c) || ';&|'.includes(c)) {
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
else if (c === '"' || c === "'") {
|
|
109
|
+
[stands, quote] = ['', c];
|
|
110
|
+
}
|
|
111
|
+
if (stands && colon) {
|
|
112
|
+
if (start < 0)
|
|
113
|
+
start = i;
|
|
114
|
+
value += stands;
|
|
115
|
+
to = i + width;
|
|
116
|
+
}
|
|
117
|
+
else if (stands === ':') {
|
|
118
|
+
colon = true;
|
|
119
|
+
}
|
|
120
|
+
i += width;
|
|
121
|
+
}
|
|
122
|
+
return { end: i, ...(value ? { password: { value, from: start, to } } : {}) };
|
|
123
|
+
}
|
|
124
|
+
/** The password of each `-u` match curl-password took, for its `secret`. */
|
|
125
|
+
const curlPasswords = new WeakMap();
|
|
126
|
+
/** A password that only names one: a variable (`$DB_PASSWORD`, `${DB_PASSWORD}`) or a placeholder (`<password>`, `{password}`). */
|
|
127
|
+
const PLACEHOLDER = /^(?:\$[A-Z_][A-Z0-9_]*|\$\{[^}]*\}|<[^>]*>|\{\{?[^}]*\}?\})$/;
|
|
39
128
|
/** A package.json or a workflow, by its path, or as git names it in a commit (`HEAD:package.json`). */
|
|
40
129
|
const PACKAGE_OR_WORKFLOW = /(?:^|[/:])package\.json$|(?:^|[/:])\.github\/workflows\/[^/]+\.ya?ml$/;
|
|
41
130
|
const NETWORK = String.raw `\b(?:curl|wget|nc|ncat|netcat)\b`;
|
|
@@ -54,6 +143,51 @@ const PATTERNS = [
|
|
|
54
143
|
shape: /(?<![A-Za-z0-9_-])(?:(?!\beyJ)[A-Za-z0-9_-])*(\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})/,
|
|
55
144
|
secret: (m) => [m.index + m[0].length - m[1].length, m.index + m[0].length],
|
|
56
145
|
},
|
|
146
|
+
{ name: 'google-api-key', context: 'output', severity: 'high', description: 'a Google API key', shape: /(?<![A-Za-z0-9_-])AIza[A-Za-z0-9_-]{35}(?![A-Za-z0-9_-])/ },
|
|
147
|
+
{ name: 'sendgrid-key', context: 'output', severity: 'high', description: 'a SendGrid key', shape: /(?<![A-Za-z0-9_-])SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/ },
|
|
148
|
+
{ name: 'slack-webhook', context: 'output', severity: 'high', description: "a Slack webhook's address", shape: /\bhooks\.slack\.com\/(?:services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]{16,}|(?:workflows|triggers)\/[A-Za-z0-9_/-]{16,})/ },
|
|
149
|
+
{
|
|
150
|
+
name: 'url-password', context: 'output', severity: 'high', description: 'a password in an address',
|
|
151
|
+
// The fence's shape for a URL's password (shellEnvironment), through its last `@` and never across a quote, so in JSON a port and the text after it up
|
|
152
|
+
// to an email address are not one.
|
|
153
|
+
shape: /:\/\/[^/@\s:"'`]*:([^/@\s"'`]+(?:@[^/@\s"'`]+)*)@/,
|
|
154
|
+
check: (m) => !PLACEHOLDER.test(m[1]),
|
|
155
|
+
secret: (m) => [m.index + m[0].length - m[1].length - 1, m.index + m[0].length - 1],
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: 'curl-password', context: 'output', severity: 'high', description: 'a password on a curl command line',
|
|
159
|
+
// Each `-u` or `--user` of curl's own command (curlCommand), with the whole word after it (curlArgument): `-uadmin:pw`, `--user=admin:pw`.
|
|
160
|
+
shape: /\s(?:-u|--user(?=[\s=]))/,
|
|
161
|
+
check: (m) => {
|
|
162
|
+
const [text, at] = [m.input, m.index];
|
|
163
|
+
const state = curlCommand(text, at);
|
|
164
|
+
if (!state.curl || at < state.argumentEnd)
|
|
165
|
+
return false;
|
|
166
|
+
let from = at + m[0].length;
|
|
167
|
+
if (text[from] === '=')
|
|
168
|
+
from++;
|
|
169
|
+
// Blanks and line continuations come before the word: `curl -u \` with ` admin:pw` on the next line, `\\\n` in a JSON string.
|
|
170
|
+
else {
|
|
171
|
+
for (;;) {
|
|
172
|
+
if (text[from] === ' ' || text[from] === '\t')
|
|
173
|
+
from++;
|
|
174
|
+
else if (text.startsWith('\\\n', from))
|
|
175
|
+
from += 2;
|
|
176
|
+
else if (state.quote === '"' && text.startsWith('\\\\\\n', from))
|
|
177
|
+
from += 4;
|
|
178
|
+
else
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const word = curlArgument(text, from, state.quote);
|
|
183
|
+
state.argumentEnd = word.end;
|
|
184
|
+
if (!word.password || PLACEHOLDER.test(word.password.value))
|
|
185
|
+
return false;
|
|
186
|
+
curlPasswords.set(m, [word.password.from, word.password.to]);
|
|
187
|
+
return true;
|
|
188
|
+
},
|
|
189
|
+
secret: (m) => curlPasswords.get(m),
|
|
190
|
+
},
|
|
57
191
|
{ name: 'impeccable-token', context: 'output', severity: 'high', description: "one of Impeccable's tokens", shape: /\b(?:pst|phr)_[A-Za-z0-9._~+/=-]{16,}/ },
|
|
58
192
|
{ name: 'secret-reference', context: 'output', severity: 'high', description: 'a reference to a password the person typed', shape: /\bsecret:[0-9a-f]{12}\b/, kept: true },
|
|
59
193
|
{
|
package/dist/sync.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { hostQuestion, TOOL_SHOWN } from './host-questions.js';
|
|
2
|
+
import { redactShapes, scan } from './scan.js';
|
|
3
|
+
import { plainText } from './ui/sensitive.js';
|
|
2
4
|
export const TASK_STATUSES = ['pending', 'in_progress', 'completed', 'skipped', 'failed'];
|
|
3
5
|
/** A PUT worth sending again: the network failed or the server did. A 4xx will not change by trying again. */
|
|
4
6
|
const retryable = (res) => Boolean(res && !res.ok && (res.status === 0 || res.status >= 500));
|
|
@@ -58,15 +60,49 @@ export function createSessionSync(send, intervalMs = 1000) {
|
|
|
58
60
|
},
|
|
59
61
|
};
|
|
60
62
|
}
|
|
61
|
-
/** The
|
|
62
|
-
export
|
|
63
|
+
/** The most a status line holds; longer text belongs in the report. */
|
|
64
|
+
export const STATUS_MAX = 70;
|
|
65
|
+
const STATUS_RULE = `Status lines are in the product voice: a present-tense verb phrase of at most ${STATUS_MAX} characters that says what is happening, such as "Running the build" or "Opening the setup pull request". No "I'm", "I am", "Let me", "I'll" or "trying to"; detail goes in the report. Nothing was saved: call impeccable_progress again with the status rephrased.`;
|
|
66
|
+
/** Why a status line breaks the voice, with the rule to rephrase it by; null when it keeps it. The line is never rewritten here. */
|
|
67
|
+
export function statusVoiceError(line) {
|
|
68
|
+
const text = line.trim();
|
|
69
|
+
const why = /^(i['’]m|i am|let me|i['’]ll)\b/i.test(text) || /\btrying to\b/i.test(text) ? 'it narrates like an assistant'
|
|
70
|
+
: text.length > STATUS_MAX ? `it is ${text.length} characters long`
|
|
71
|
+
: plainText(text) !== text ? 'it holds a control character or an escape sequence' : null;
|
|
72
|
+
return why ? `Refused: "${text}" does not fit the status line, because ${why}. ${STATUS_RULE}` : null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The text an assistant message starts with: the `[STATUS]` line the playbook asks for, when there is one that impeccable_progress
|
|
76
|
+
* would take, so the web page and the screen never show what the tool refuses: a line out of the voice or over its length, or one
|
|
77
|
+
* holding text shaped like a secret (which the fence refuses the tool). `scans` takes that refusal as the fence's own: the check and where, never the text.
|
|
78
|
+
*/
|
|
79
|
+
export function statusLine(message, scans) {
|
|
63
80
|
if (message.role !== 'assistant' || !Array.isArray(message.content))
|
|
64
81
|
return null;
|
|
65
82
|
const first = message.content.find((c) => c.type === 'text');
|
|
66
83
|
const text = first?.text?.trimStart() ?? '';
|
|
67
84
|
if (!text.startsWith('[STATUS]'))
|
|
68
85
|
return null;
|
|
69
|
-
|
|
86
|
+
const line = text.slice('[STATUS]'.length).split('\n')[0].trim();
|
|
87
|
+
if (!line)
|
|
88
|
+
return null;
|
|
89
|
+
// Read before the voice, as the fence reads impeccable_progress before the tool does, so a refusal is recorded whatever else is wrong with the line.
|
|
90
|
+
const hit = scan('output', `status: ${line}`)[0];
|
|
91
|
+
if (hit)
|
|
92
|
+
scans?.record({ pattern: hit.name, severity: hit.severity, sink: '[STATUS] line', tool: 'message', action: 'block' });
|
|
93
|
+
return hit || statusVoiceError(line) ? null : line;
|
|
94
|
+
}
|
|
95
|
+
/** The longest status the Worker takes: a longer one makes it refuse the whole update, and the web page stops following the run. */
|
|
96
|
+
const PAGE_STATUS_MAX = 500;
|
|
97
|
+
/** A status the command words itself around the gateway's or the model's words, such as why the run stopped: nothing in it shaped like a secret or a terminal acts on, and no longer than the Worker takes. */
|
|
98
|
+
export function pageStatus(text) {
|
|
99
|
+
// Controls come out first: one inside a token would hide it from the scan, and their removal would join it again.
|
|
100
|
+
const plain = redactShapes(plainText(text));
|
|
101
|
+
return plain.length > PAGE_STATUS_MAX ? `${plain.slice(0, PAGE_STATUS_MAX - 1)}…` : plain;
|
|
102
|
+
}
|
|
103
|
+
/** The report as the terminal prints it when the web page missed it: plain text, then redacted, for the reason pageStatus gives. */
|
|
104
|
+
export function plainReport(markdown) {
|
|
105
|
+
return redactShapes(plainText(markdown.trim()));
|
|
70
106
|
}
|
|
71
107
|
/** A model error in plain words: the `error.message` of an API error body when there is one. */
|
|
72
108
|
export function plainError(text) {
|
|
@@ -83,10 +119,11 @@ export function plainError(text) {
|
|
|
83
119
|
}
|
|
84
120
|
/**
|
|
85
121
|
* Session sync: session start posts "running" with no tasks, each `[STATUS]`
|
|
86
|
-
* line becomes the status
|
|
87
|
-
*
|
|
122
|
+
* line becomes the status (one refused for a secret's shape goes to `scans`),
|
|
123
|
+
* a question from impeccable_ask shows the run as waiting until it is
|
|
124
|
+
* answered, and a quit hands over to the command's finish.
|
|
88
125
|
*/
|
|
89
|
-
export function syncExtension(sync, hooks) {
|
|
126
|
+
export function syncExtension(sync, hooks, scans) {
|
|
90
127
|
return (pi) => {
|
|
91
128
|
let last;
|
|
92
129
|
/** Questions asked and not yet answered, by tool call, in the order asked: the terminal shows the first, so the web page does too, and never an answer. */
|
|
@@ -124,7 +161,7 @@ export function syncExtension(sync, hooks) {
|
|
|
124
161
|
if (message.role !== 'assistant')
|
|
125
162
|
return;
|
|
126
163
|
last = message;
|
|
127
|
-
const status = statusLine(message);
|
|
164
|
+
const status = statusLine(message, scans);
|
|
128
165
|
if (status)
|
|
129
166
|
sync.update({ status });
|
|
130
167
|
});
|