@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.
Files changed (41) hide show
  1. package/README.md +21 -20
  2. package/dist/agent.js +9 -4
  3. package/dist/cloudflare-access.js +77 -47
  4. package/dist/cloudflare.js +325 -23
  5. package/dist/fence.js +939 -143
  6. package/dist/focus.js +5 -4
  7. package/dist/host-previews.js +107 -0
  8. package/dist/host-questions.js +90 -20
  9. package/dist/hosted.js +72 -21
  10. package/dist/http.js +2 -0
  11. package/dist/log.js +7 -1
  12. package/dist/login.js +45 -13
  13. package/dist/main.js +32 -17
  14. package/dist/netlify-password.js +8 -6
  15. package/dist/netlify.js +43 -14
  16. package/dist/pi-paths.js +69 -0
  17. package/dist/playbook/cases/cloudflare-previews.md +12 -4
  18. package/dist/playbook/cases/first-preview-failure.md +1 -1
  19. package/dist/playbook/cases/live-app-address.md +15 -0
  20. package/dist/playbook/cases/monorepo.md +1 -1
  21. package/dist/playbook/cases/netlify-previews.md +3 -1
  22. package/dist/playbook/cases/netlify-setup.md +8 -7
  23. package/dist/playbook/cases/preview-protection.md +2 -1
  24. package/dist/playbook/cases/pull-requests.md +4 -4
  25. package/dist/playbook/cases/static-site.md +2 -2
  26. package/dist/playbook/cases/vercel-previews.md +2 -0
  27. package/dist/playbook/cases/vercel-setup.md +9 -6
  28. package/dist/playbook.js +2 -2
  29. package/dist/preflight.js +36 -7
  30. package/dist/report.js +1 -0
  31. package/dist/sandbox.js +364 -50
  32. package/dist/scan.js +134 -0
  33. package/dist/sync.js +44 -7
  34. package/dist/tools.js +397 -88
  35. package/dist/ui/bridge.js +18 -4
  36. package/dist/ui/look.js +1 -1
  37. package/dist/ui/screens.js +5 -3
  38. package/dist/ui/sensitive.js +10 -0
  39. package/dist/vercel-bypass.js +12 -7
  40. package/dist/vercel.js +110 -17
  41. 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 text an assistant message starts with: the `[STATUS]` line the playbook asks for, when there is one. */
62
- export function statusLine(message) {
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
- return text.slice('[STATUS]'.length).split('\n')[0].trim() || null;
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, a question from impeccable_ask shows the run as
87
- * waiting until it is answered, and a quit hands over to the command's finish.
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
  });