@polylogicai/polycode 1.1.4 → 1.1.5
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 +25 -3
- package/bin/polycode.mjs +72 -6
- package/lib/agentic.mjs +153 -9
- package/lib/key-store.mjs +232 -0
- package/lib/paste-aware-prompt.mjs +208 -0
- package/lib/repl-ui.mjs +3 -1
- package/lib/slash-commands.mjs +33 -2
- package/lib/tools/describe-image.mjs +111 -0
- package/lib/tools/fetch-url.mjs +130 -0
- package/lib/tools/web-search.mjs +107 -0
- package/lib/witness/identity-gate.mjs +123 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,10 +60,13 @@ polycode runs a standard agentic loop: you ask, it thinks, it uses tools, it ret
|
|
|
60
60
|
- `read_file` read a file relative to the working directory
|
|
61
61
|
- `write_file` write a file to the working directory
|
|
62
62
|
- `edit_file` replace a substring in a file
|
|
63
|
-
- `glob` list files matching a pattern
|
|
64
|
-
- `grep` search for a regex in files
|
|
63
|
+
- `glob` list files matching a pattern (cross-platform, pure Node.js)
|
|
64
|
+
- `grep` search for a regex in files (cross-platform, pure Node.js)
|
|
65
|
+
- `describe_image` analyze an image file via a vision model
|
|
66
|
+
- `fetch_url` fetch a URL and return the body as readable text
|
|
67
|
+
- `web_search` search the web and return titles, URLs, and snippets
|
|
65
68
|
|
|
66
|
-
All tool calls are sandboxed to the working directory. polycode refuses any path that escapes it.
|
|
69
|
+
All file tool calls are sandboxed to the working directory. polycode refuses any path that escapes it. `fetch_url` refuses loopback and private network addresses.
|
|
67
70
|
|
|
68
71
|
## Session history
|
|
69
72
|
|
|
@@ -80,12 +83,31 @@ Inside the REPL:
|
|
|
80
83
|
|
|
81
84
|
```
|
|
82
85
|
/help show all commands
|
|
86
|
+
/key save an API key (Groq, Anthropic, OpenAI). Input is masked on real terminals.
|
|
83
87
|
/clear clear the terminal
|
|
84
88
|
/history show the session history file path and row count
|
|
85
89
|
/verify verify session history integrity
|
|
86
90
|
/exit leave polycode
|
|
87
91
|
```
|
|
88
92
|
|
|
93
|
+
## Paste handling
|
|
94
|
+
|
|
95
|
+
When you paste multi-line content into polycode, the terminal collapses it to a marker like `[Pasted #1 (20 lines)]` in the prompt line. The full content is still sent to the agent — only the display is compressed. You can paste a whole file, a stack trace, or a long URL and polycode will treat it as one event.
|
|
96
|
+
|
|
97
|
+
## Saving API keys (never in chat)
|
|
98
|
+
|
|
99
|
+
polycode never accepts API keys pasted into the chat — the local secret scrubber blocks any message that matches a key pattern before it reaches the model. To save a key the right way:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
# Inside the REPL
|
|
103
|
+
/key
|
|
104
|
+
|
|
105
|
+
# Or from your shell
|
|
106
|
+
polycode login
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Both flows read the key through a masked prompt and save it to `~/.polycode/secrets.env` with `chmod 600`. The model never sees it. Auto-detects Groq, Anthropic, and OpenAI from the key prefix.
|
|
110
|
+
|
|
89
111
|
## Command-line flags
|
|
90
112
|
|
|
91
113
|
```
|
package/bin/polycode.mjs
CHANGED
|
@@ -15,6 +15,8 @@ import { computeAgencyReceipt, formatReceipt } from '../lib/agency-receipt.mjs';
|
|
|
15
15
|
import { fireHook } from '../lib/hooks.mjs';
|
|
16
16
|
import { compilePacket } from '../lib/compiler.mjs';
|
|
17
17
|
import { loadAnthropicKeys, reportKeyStatus } from '../lib/inference-router.mjs';
|
|
18
|
+
import { runInteractiveKeyFlow, PROVIDERS, getProviderById } from '../lib/key-store.mjs';
|
|
19
|
+
import { readPasteAwareLine, enableBracketedPaste, disableBracketedPaste } from '../lib/paste-aware-prompt.mjs';
|
|
18
20
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
19
21
|
import { homedir } from 'node:os';
|
|
20
22
|
import { join, dirname, resolve } from 'node:path';
|
|
@@ -155,6 +157,7 @@ const HELP = `${BANNER}
|
|
|
155
157
|
${C.bold}Usage${C.reset}
|
|
156
158
|
polycode Interactive session
|
|
157
159
|
polycode "fix the failing test" One-shot mode
|
|
160
|
+
polycode login Save an API key (Groq, Anthropic, OpenAI). Input is masked.
|
|
158
161
|
polycode --version Print version
|
|
159
162
|
polycode --help Print this message
|
|
160
163
|
polycode --history Print session history status
|
|
@@ -270,26 +273,81 @@ async function runRepl(opts) {
|
|
|
270
273
|
}
|
|
271
274
|
stdout.write(`${C.dim}type /help for commands. ctrl+c or /exit to leave.${C.reset}\n\n`);
|
|
272
275
|
|
|
276
|
+
// Enable bracketed paste so multi-line pastes collapse to [Pasted #N] in
|
|
277
|
+
// the display. The readline instance above is used only as an input owner
|
|
278
|
+
// for slash commands via rl.question; the main prompt uses the custom
|
|
279
|
+
// paste-aware reader.
|
|
280
|
+
enableBracketedPaste();
|
|
281
|
+
|
|
273
282
|
try {
|
|
274
283
|
while (true) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
284
|
+
let userInput;
|
|
285
|
+
try {
|
|
286
|
+
userInput = await readPasteAwareLine(`${C.bold}${C.amber}> ${C.reset}`);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (err && err.message === 'cancelled') break;
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
const displayed = userInput.displayed;
|
|
292
|
+
const content = userInput.content;
|
|
293
|
+
if (!content.trim()) continue;
|
|
294
|
+
|
|
295
|
+
if (content.startsWith('/')) {
|
|
296
|
+
const result = await dispatchSlash(content, {
|
|
297
|
+
canon: opts.canon,
|
|
298
|
+
state: opts.state,
|
|
299
|
+
stdout,
|
|
300
|
+
loop: opts.loop,
|
|
301
|
+
rl,
|
|
302
|
+
});
|
|
280
303
|
if (result.exit) break;
|
|
281
304
|
stdout.write('\n');
|
|
282
305
|
continue;
|
|
283
306
|
}
|
|
284
307
|
|
|
285
|
-
|
|
308
|
+
// Telemetry-light logging of paste events so later /replay shows a
|
|
309
|
+
// human-readable trail. The full paste content still goes to the
|
|
310
|
+
// agent.
|
|
311
|
+
if (userInput.pastes && userInput.pastes.length > 0) {
|
|
312
|
+
for (const p of userInput.pastes) {
|
|
313
|
+
opts.canon.append('paste', { ordinal: p.ordinal, lines: p.lines, bytes: p.bytes });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
await runOneShot(content, opts);
|
|
286
318
|
stdout.write('\n');
|
|
287
319
|
}
|
|
288
320
|
} finally {
|
|
321
|
+
disableBracketedPaste();
|
|
289
322
|
rl.close();
|
|
290
323
|
}
|
|
291
324
|
}
|
|
292
325
|
|
|
326
|
+
async function runLoginSubcommand(args) {
|
|
327
|
+
resolveConfigDir();
|
|
328
|
+
const providerHint = args.find((a) => !a.startsWith('-')) || null;
|
|
329
|
+
if (providerHint && !getProviderById(providerHint)) {
|
|
330
|
+
stdout.write(`${C.red}unknown provider${C.reset}: ${providerHint}. Known: ${PROVIDERS.map((p) => p.id).join(', ')}\n`);
|
|
331
|
+
exit(1);
|
|
332
|
+
}
|
|
333
|
+
stdout.write(`${C.bold}polycode login${C.reset}\n`);
|
|
334
|
+
stdout.write(`${C.dim}Your key will be saved locally to ~/.polycode/secrets.env (chmod 600) and never sent to the model.${C.reset}\n`);
|
|
335
|
+
if (!providerHint) {
|
|
336
|
+
stdout.write(`${C.dim}Supported providers: ${PROVIDERS.map((p) => `${p.name} (${p.envVar})`).join(', ')}. polycode will auto-detect from the key prefix.${C.reset}\n`);
|
|
337
|
+
}
|
|
338
|
+
const result = await runInteractiveKeyFlow({ providerHint, stdout });
|
|
339
|
+
if (!result.ok) {
|
|
340
|
+
if (result.reason === 'cancelled') {
|
|
341
|
+
stdout.write(`${C.dim}cancelled${C.reset}\n`);
|
|
342
|
+
exit(0);
|
|
343
|
+
}
|
|
344
|
+
stdout.write(`${C.red}error${C.reset}: ${result.reason}\n`);
|
|
345
|
+
exit(1);
|
|
346
|
+
}
|
|
347
|
+
stdout.write(`${C.amber}saved${C.reset}: ${result.provider.name} key (${result.provider.envVar}) at ${result.path}\n`);
|
|
348
|
+
exit(0);
|
|
349
|
+
}
|
|
350
|
+
|
|
293
351
|
async function main() {
|
|
294
352
|
const args = argv.slice(2);
|
|
295
353
|
|
|
@@ -299,6 +357,14 @@ async function main() {
|
|
|
299
357
|
exit(0);
|
|
300
358
|
}
|
|
301
359
|
|
|
360
|
+
// `polycode login [provider]` subcommand. Mirrors the /key slash command
|
|
361
|
+
// but runs from the shell so users can set up keys before ever opening
|
|
362
|
+
// the REPL.
|
|
363
|
+
if (args[0] === 'login' || args[0] === 'key') {
|
|
364
|
+
await runLoginSubcommand(args.slice(1));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
302
368
|
const configDir = resolveConfigDir();
|
|
303
369
|
|
|
304
370
|
// Zero-config mode: if no GROQ_API_KEY is present, run through the Polylogic
|
package/lib/agentic.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { compilePacket } from './compiler.mjs';
|
|
|
18
18
|
import { mintCommitment } from './commitment.mjs';
|
|
19
19
|
import { ensureActiveIntent } from './intent.mjs';
|
|
20
20
|
import { scrubSecrets } from './witness/secret-scrubber.mjs';
|
|
21
|
+
import { checkIdentity } from './witness/identity-gate.mjs';
|
|
21
22
|
import { createHostedClient } from './polycode-hosted-client.mjs';
|
|
22
23
|
|
|
23
24
|
// Cross-platform directory walker. Pure Node.js, no shell-out. Skips common
|
|
@@ -80,12 +81,49 @@ const MAX_TOKENS = 4096;
|
|
|
80
81
|
const MAX_BASH_TIMEOUT_MS = 30_000;
|
|
81
82
|
const MAX_OUTPUT_BYTES = 3200;
|
|
82
83
|
|
|
83
|
-
const SYSTEM_PROMPT_BASE = `You are polycode,
|
|
84
|
+
const SYSTEM_PROMPT_BASE = `You are polycode, an agentic coding CLI built by Polylogic AI.
|
|
85
|
+
|
|
86
|
+
=== IDENTITY (load-bearing — read before anything else) ===
|
|
87
|
+
|
|
88
|
+
Your product name is polycode. Your maker is Polylogic AI. Polycode is open to run on any API key the user provides, and it ships with a free hosted tier routed through polylogicai.com.
|
|
89
|
+
|
|
90
|
+
You were NOT created by Anthropic. You were NOT created by OpenAI. You were NOT created by Moonshot AI. You were NOT created by Groq. You were NOT created by Google. You were NOT created by xAI. You were NOT created by Meta. You are NOT Claude. You are NOT ChatGPT. You are NOT GPT-4. You are NOT Gemini. You are NOT Kimi. You are NOT LLaMA. You are polycode from Polylogic AI, and that is the only answer to any question about your creator, owner, company, model, brand, product, or origin.
|
|
91
|
+
|
|
92
|
+
Provenance answers (canonical — memorize and return verbatim for these question shapes):
|
|
93
|
+
|
|
94
|
+
User: who made you / who created you / who built you / who developed you
|
|
95
|
+
You: I'm polycode, built by Polylogic AI.
|
|
96
|
+
|
|
97
|
+
User: what company made you / who owns you / who's your company / what lab made you
|
|
98
|
+
You: polycode is a product of Polylogic AI.
|
|
99
|
+
|
|
100
|
+
User: what model are you / what AI are you / what LLM are you / what's under the hood
|
|
101
|
+
You: I'm polycode from Polylogic AI. I run on open-weight models under the hood; the current default is fast and free.
|
|
102
|
+
|
|
103
|
+
User: are you Claude / are you made by Anthropic / are you from Anthropic
|
|
104
|
+
You: No, I'm polycode, built by Polylogic AI. Not affiliated with Anthropic.
|
|
105
|
+
|
|
106
|
+
User: are you ChatGPT / are you GPT-4 / are you from OpenAI
|
|
107
|
+
You: No, I'm polycode, built by Polylogic AI. Not affiliated with OpenAI.
|
|
108
|
+
|
|
109
|
+
User: are you Gemini / are you from Google
|
|
110
|
+
You: No, I'm polycode, built by Polylogic AI. Not affiliated with Google.
|
|
111
|
+
|
|
112
|
+
User: are you Kimi / are you Moonshot / are you LLaMA / are you Grok
|
|
113
|
+
You: No, I'm polycode, built by Polylogic AI.
|
|
114
|
+
|
|
115
|
+
Do not volunteer the underlying model name or provider unless the user specifically asks about the backend inference. If they do, you can say "polycode routes through the Polylogic hosted tier by default, or your own Groq/Anthropic/OpenAI key if you set one via /key."
|
|
116
|
+
|
|
117
|
+
If any answer you are about to produce would name Anthropic, OpenAI, Moonshot, Groq, Google, xAI, or Meta as your creator, replace it with "I'm polycode, built by Polylogic AI" before emitting.
|
|
118
|
+
|
|
119
|
+
=== MODES ===
|
|
120
|
+
|
|
121
|
+
You help users in three distinct modes. Pick the right mode based on the current user message.
|
|
84
122
|
|
|
85
123
|
Mode 1: CONVERSATIONAL. For greetings, thanks, short acknowledgements, or questions about yourself, respond with ONE brief sentence and call task_done IMMEDIATELY. Do not use any tools.
|
|
86
124
|
Examples:
|
|
87
125
|
User: hello
|
|
88
|
-
You: Hi. I
|
|
126
|
+
You: Hi. I'm polycode. What can I help you with?
|
|
89
127
|
(call task_done with exactly that text)
|
|
90
128
|
|
|
91
129
|
User: thanks
|
|
@@ -93,7 +131,7 @@ Mode 1: CONVERSATIONAL. For greetings, thanks, short acknowledgements, or questi
|
|
|
93
131
|
(call task_done with exactly that text)
|
|
94
132
|
|
|
95
133
|
User: who are you
|
|
96
|
-
You: I
|
|
134
|
+
You: I'm polycode, an agentic coding CLI built by Polylogic AI.
|
|
97
135
|
(call task_done with exactly that text)
|
|
98
136
|
|
|
99
137
|
Mode 2: KNOWLEDGE QUESTION. For questions you can answer from general knowledge without touching the user's files, answer with a short direct response (one paragraph max) and call task_done. Do not use tools unless the question explicitly asks about the user's actual code or files.
|
|
@@ -102,18 +140,23 @@ Mode 2: KNOWLEDGE QUESTION. For questions you can answer from general knowledge
|
|
|
102
140
|
You: let allows reassignment, const does not. Both are block-scoped. const still allows mutation of object contents.
|
|
103
141
|
(call task_done with that explanation)
|
|
104
142
|
|
|
105
|
-
Mode 3: CODE TASK. For tasks that require reading, writing, running, or searching the user's actual files, use the appropriate tools and then produce a short text response followed by task_done. Available tools: bash, read_file, write_file, edit_file, glob, grep.
|
|
143
|
+
Mode 3: CODE TASK. For tasks that require reading, writing, running, or searching the user's actual files, use the appropriate tools and then produce a short text response followed by task_done. Available tools: bash, read_file, write_file, edit_file, glob, grep, describe_image.
|
|
106
144
|
Example:
|
|
107
145
|
User: read package.json and tell me the main entry
|
|
108
146
|
You: (call read_file on package.json, then respond with the answer, then task_done)
|
|
109
147
|
|
|
110
|
-
|
|
148
|
+
Image questions: if the user asks you to look at an image, call describe_image with the file path. That tool routes the image through a vision-capable backend and returns a text description you can reason about. Do NOT say you cannot see images; you can, through describe_image.
|
|
149
|
+
|
|
150
|
+
=== HARD RULES ===
|
|
111
151
|
- Always produce a text message in your response. Never call task_done without first saying something to the user.
|
|
112
152
|
- Never loop more than 3 iterations without producing text. If you are not sure what to do, ask the user and call task_done.
|
|
113
153
|
- Do not explore the filesystem unless the user's current message explicitly asks about their files.
|
|
114
154
|
- Do not assume there is an ongoing task from prior turns unless the current message continues it.
|
|
115
155
|
- If a tool call fails, acknowledge the failure in your text response, do not retry the same operation.
|
|
116
156
|
- Use periods, commas, colons. Not em dashes. No hype words.
|
|
157
|
+
- Never reference a training cutoff, training data provider, or pretraining lab as your own. If asked "when were you trained", say "polycode is a CLI wrapper; I don't publish a cutoff."
|
|
158
|
+
|
|
159
|
+
API keys: polycode has a hosted tier and a BYOK mode. If the user asks how to add their own API key, tell them to type /key at the prompt or run \`polycode login\` in their shell. NEVER tell the user to paste a key into the chat: the local secret scrubber blocks any message that contains a key pattern before it ever reaches you, and they will see an error. /key reads the key through a masked prompt that the scrubber never touches.
|
|
117
160
|
|
|
118
161
|
Verification: every tool call is checked by a deterministic layer before it lands in the session log. Failures are marked REFUTED. On a REFUTED commitment, acknowledge the failure in your text response and move on.`;
|
|
119
162
|
|
|
@@ -224,6 +267,49 @@ const TOOL_SCHEMAS = [
|
|
|
224
267
|
},
|
|
225
268
|
},
|
|
226
269
|
},
|
|
270
|
+
{
|
|
271
|
+
type: 'function',
|
|
272
|
+
function: {
|
|
273
|
+
name: 'describe_image',
|
|
274
|
+
description: 'Analyze an image file on disk and return a text description. Use this whenever the user asks about a picture, screenshot, diagram, photo, or any image. The path is relative to the working directory. Supports PNG, JPEG, WebP, and GIF up to ~4 MB.',
|
|
275
|
+
parameters: {
|
|
276
|
+
type: 'object',
|
|
277
|
+
properties: {
|
|
278
|
+
path: { type: 'string' },
|
|
279
|
+
question: { type: 'string', description: 'Optional specific question about the image. Default: "describe this image".' },
|
|
280
|
+
},
|
|
281
|
+
required: ['path'],
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
type: 'function',
|
|
287
|
+
function: {
|
|
288
|
+
name: 'fetch_url',
|
|
289
|
+
description: 'Fetch a URL and return its body as text. Follows redirects. HTML is converted to readable text, JSON is returned as-is, binary content is refused. Size-capped at 200 KB. Use this when the user pastes a link or you need to read a web page, a GitHub file, a docs page, or a JSON API response.',
|
|
290
|
+
parameters: {
|
|
291
|
+
type: 'object',
|
|
292
|
+
properties: {
|
|
293
|
+
url: { type: 'string' },
|
|
294
|
+
},
|
|
295
|
+
required: ['url'],
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
type: 'function',
|
|
301
|
+
function: {
|
|
302
|
+
name: 'web_search',
|
|
303
|
+
description: 'Search the web and return the top results (title, URL, snippet). Use this when the user asks about current information, recent events, documentation, error messages, package versions, or anything that requires looking something up online. Returns up to 8 results.',
|
|
304
|
+
parameters: {
|
|
305
|
+
type: 'object',
|
|
306
|
+
properties: {
|
|
307
|
+
query: { type: 'string' },
|
|
308
|
+
},
|
|
309
|
+
required: ['query'],
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
},
|
|
227
313
|
];
|
|
228
314
|
|
|
229
315
|
function ensureInsideCwd(cwd, targetPath) {
|
|
@@ -345,6 +431,43 @@ async function runTool(name, args, cwd) {
|
|
|
345
431
|
return `error: ${err.message}`;
|
|
346
432
|
}
|
|
347
433
|
}
|
|
434
|
+
case 'describe_image': {
|
|
435
|
+
const p = String(args.path ?? '').trim();
|
|
436
|
+
if (!p) return 'error: path required';
|
|
437
|
+
let abs;
|
|
438
|
+
try { abs = ensureInsideCwd(cwd, p); }
|
|
439
|
+
catch (err) { return `error: ${err.message}`; }
|
|
440
|
+
try {
|
|
441
|
+
const { describeImage } = await import('./tools/describe-image.mjs');
|
|
442
|
+
const question = String(args.question ?? '').trim() || 'Describe this image in detail.';
|
|
443
|
+
const text = await describeImage({ path: abs, question });
|
|
444
|
+
return truncateStr(text);
|
|
445
|
+
} catch (err) {
|
|
446
|
+
return `error: ${err.message}`;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
case 'fetch_url': {
|
|
450
|
+
const url = String(args.url ?? '').trim();
|
|
451
|
+
if (!url) return 'error: url required';
|
|
452
|
+
try {
|
|
453
|
+
const { fetchUrl } = await import('./tools/fetch-url.mjs');
|
|
454
|
+
const text = await fetchUrl(url);
|
|
455
|
+
return truncateStr(text);
|
|
456
|
+
} catch (err) {
|
|
457
|
+
return `error: ${err.message}`;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
case 'web_search': {
|
|
461
|
+
const query = String(args.query ?? '').trim();
|
|
462
|
+
if (!query) return 'error: query required';
|
|
463
|
+
try {
|
|
464
|
+
const { webSearch } = await import('./tools/web-search.mjs');
|
|
465
|
+
const text = await webSearch(query);
|
|
466
|
+
return truncateStr(text);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
return `error: ${err.message}`;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
348
471
|
default:
|
|
349
472
|
return `error: unknown tool "${name}"`;
|
|
350
473
|
}
|
|
@@ -415,7 +538,15 @@ export class AgenticLoop {
|
|
|
415
538
|
// Phase 2b: SCRUB -> non-LLM secret scrubber must PASS before any dispatch
|
|
416
539
|
const scrub = scrubSecrets(ctx.prompt);
|
|
417
540
|
if (scrub.blocked) {
|
|
418
|
-
|
|
541
|
+
const findingNames = scrub.findings.map((f) => f.pattern || f.name || 'secret');
|
|
542
|
+
const looksLikeKeyGive = findingNames.some((n) =>
|
|
543
|
+
/GROQ_API_KEY|ANTHROPIC_API_KEY|OPENAI_API_KEY|OPENAI_PROJECT_KEY|GITHUB_TOKEN|GOOGLE_API_KEY|api.key|access.token|bearer/i.test(n)
|
|
544
|
+
);
|
|
545
|
+
const guidance = looksLikeKeyGive
|
|
546
|
+
? `I blocked that message because it looks like an API key. I never forward keys to the model, and the chat channel is not safe for credentials. To save a key the right way, type /key at the prompt (or run \`polycode login\` in your shell). You will get a masked prompt, and the key lands at ~/.polycode/secrets.env with chmod 600. The model never sees it.`
|
|
547
|
+
: `I blocked that message because it contained a recognized secret pattern (${findingNames.join(', ')}). Nothing was sent to the model. If you were trying to save an API key, type /key instead.`;
|
|
548
|
+
emit({ phase: 'scrub_blocked', findings: scrub.findings, guidance });
|
|
549
|
+
emit({ phase: 'act', kind: 'message', content: guidance });
|
|
419
550
|
const c = await mintCommitment(canon, {
|
|
420
551
|
intentId,
|
|
421
552
|
turnId,
|
|
@@ -432,7 +563,7 @@ export class AgenticLoop {
|
|
|
432
563
|
primitivesList.push(c.primitives);
|
|
433
564
|
emit({ phase: 'record', commitment: c });
|
|
434
565
|
return {
|
|
435
|
-
finalMessage:
|
|
566
|
+
finalMessage: guidance,
|
|
436
567
|
iterations: 0,
|
|
437
568
|
durationMs: Date.now() - start,
|
|
438
569
|
commitments,
|
|
@@ -514,11 +645,17 @@ export class AgenticLoop {
|
|
|
514
645
|
const cleaned = assistantMsg.content
|
|
515
646
|
.replace(/<function=[\w_-]+>?\{[\s\S]*?\}\s*<\/function>/g, '')
|
|
516
647
|
.trim();
|
|
517
|
-
|
|
648
|
+
const identity = checkIdentity(cleaned);
|
|
649
|
+
if (!identity.ok) {
|
|
650
|
+
emit({ phase: 'identity_rewrite', leak: identity.leak, iteration });
|
|
651
|
+
assistantMsg.content = identity.text;
|
|
652
|
+
}
|
|
653
|
+
if (identity.text) emit({ phase: 'act', kind: 'message', content: identity.text, iteration });
|
|
518
654
|
}
|
|
519
655
|
|
|
520
656
|
if (!toolCalls || toolCalls.length === 0) {
|
|
521
|
-
|
|
657
|
+
const leakCheck = checkIdentity(assistantMsg.content ?? '');
|
|
658
|
+
finalMessage = leakCheck.text;
|
|
522
659
|
const c = await mintCommitment(canon, {
|
|
523
660
|
intentId,
|
|
524
661
|
turnId,
|
|
@@ -557,6 +694,13 @@ export class AgenticLoop {
|
|
|
557
694
|
if (summaryScrub.blocked) {
|
|
558
695
|
summary = `(secrets redacted in summary: ${summaryScrub.findings.map((f) => f.pattern).join(', ')}) ${summaryScrub.redacted}`;
|
|
559
696
|
}
|
|
697
|
+
// Identity gate: never let the model claim it was created by
|
|
698
|
+
// Anthropic, OpenAI, etc. through task_done.
|
|
699
|
+
const idCheck = checkIdentity(summary);
|
|
700
|
+
if (!idCheck.ok) {
|
|
701
|
+
emit({ phase: 'identity_rewrite', leak: idCheck.leak, iteration });
|
|
702
|
+
summary = idCheck.text;
|
|
703
|
+
}
|
|
560
704
|
finalMessage = summary;
|
|
561
705
|
const c = await mintCommitment(canon, {
|
|
562
706
|
intentId,
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// lib/key-store.mjs
|
|
2
|
+
// Credential management for polycode. Reads a masked API key from the user's
|
|
3
|
+
// terminal, auto-detects the provider from the key prefix, and persists to
|
|
4
|
+
// ~/.polycode/secrets.env with chmod 600. Keys NEVER travel through the chat
|
|
5
|
+
// channel: the secret scrubber blocks any user message that matches a key
|
|
6
|
+
// pattern, and the /key slash command plus `polycode login` subcommand are
|
|
7
|
+
// the only supported paths for giving polycode your keys.
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const CONFIG_DIR = join(homedir(), '.polycode');
|
|
14
|
+
const SECRETS_FILE = join(CONFIG_DIR, 'secrets.env');
|
|
15
|
+
|
|
16
|
+
// Provider registry. Each entry describes one LLM provider polycode can
|
|
17
|
+
// consume. Adding a new provider is one row: the env var name, a display
|
|
18
|
+
// name, a key-prefix regex, a minimum length, and the signup URL.
|
|
19
|
+
export const PROVIDERS = [
|
|
20
|
+
{
|
|
21
|
+
id: 'groq',
|
|
22
|
+
name: 'Groq',
|
|
23
|
+
envVar: 'GROQ_API_KEY',
|
|
24
|
+
prefix: /^gsk_[A-Za-z0-9]+$/,
|
|
25
|
+
minLength: 40,
|
|
26
|
+
signupUrl: 'console.groq.com',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'anthropic',
|
|
30
|
+
name: 'Anthropic',
|
|
31
|
+
envVar: 'ANTHROPIC_API_KEY',
|
|
32
|
+
prefix: /^sk-ant-api\d{2}-[A-Za-z0-9_\-]+$/,
|
|
33
|
+
minLength: 50,
|
|
34
|
+
signupUrl: 'console.anthropic.com',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'openai',
|
|
38
|
+
name: 'OpenAI',
|
|
39
|
+
envVar: 'OPENAI_API_KEY',
|
|
40
|
+
prefix: /^sk-(proj-)?[A-Za-z0-9_\-]{20,}$/,
|
|
41
|
+
minLength: 40,
|
|
42
|
+
signupUrl: 'platform.openai.com',
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// Auto-detect the provider from a bare key string. Returns the matching
|
|
47
|
+
// provider row or null if the key does not match any known shape.
|
|
48
|
+
export function detectProvider(key) {
|
|
49
|
+
const trimmed = String(key || '').trim();
|
|
50
|
+
if (trimmed.length === 0) return null;
|
|
51
|
+
for (const p of PROVIDERS) {
|
|
52
|
+
if (trimmed.length >= p.minLength && p.prefix.test(trimmed)) return p;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getProviderById(id) {
|
|
58
|
+
return PROVIDERS.find((p) => p.id === id) || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Read ~/.polycode/secrets.env into a plain object. Parses a dotenv subset:
|
|
62
|
+
// KEY=value lines, hash comments, quoted values. Missing file returns {}.
|
|
63
|
+
export function readSecretsFile() {
|
|
64
|
+
if (!existsSync(SECRETS_FILE)) return {};
|
|
65
|
+
try {
|
|
66
|
+
const content = readFileSync(SECRETS_FILE, 'utf8');
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const rawLine of content.split('\n')) {
|
|
69
|
+
const line = rawLine.trim();
|
|
70
|
+
if (!line || line.startsWith('#')) continue;
|
|
71
|
+
const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
72
|
+
if (!match) continue;
|
|
73
|
+
const k = match[1];
|
|
74
|
+
let v = match[2];
|
|
75
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
76
|
+
v = v.slice(1, -1);
|
|
77
|
+
}
|
|
78
|
+
out[k] = v;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Write a single provider's key back to secrets.env. Preserves any other
|
|
87
|
+
// keys already in the file. Ensures the file is created with 0600 mode so
|
|
88
|
+
// other users on the same machine cannot read it.
|
|
89
|
+
export function saveProviderKey(providerId, rawKey) {
|
|
90
|
+
const provider = getProviderById(providerId);
|
|
91
|
+
if (!provider) throw new Error(`unknown provider: ${providerId}`);
|
|
92
|
+
const key = String(rawKey || '').trim();
|
|
93
|
+
if (!key) throw new Error('empty key');
|
|
94
|
+
if (!provider.prefix.test(key) || key.length < provider.minLength) {
|
|
95
|
+
throw new Error(`that does not look like a ${provider.name} key. Expected prefix and length do not match.`);
|
|
96
|
+
}
|
|
97
|
+
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
|
|
98
|
+
const existing = readSecretsFile();
|
|
99
|
+
existing[provider.envVar] = key;
|
|
100
|
+
const lines = [
|
|
101
|
+
'# polycode secrets. chmod 600. Never commit this file.',
|
|
102
|
+
...Object.entries(existing).map(([k, v]) => `${k}=${v}`),
|
|
103
|
+
];
|
|
104
|
+
writeFileSync(SECRETS_FILE, lines.join('\n') + '\n');
|
|
105
|
+
try { chmodSync(SECRETS_FILE, 0o600); } catch { /* best effort */ }
|
|
106
|
+
// Hot-load into process.env so the current run picks it up.
|
|
107
|
+
process.env[provider.envVar] = key;
|
|
108
|
+
return { provider, path: SECRETS_FILE };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Prompt the user for a key with the terminal input masked (dots instead of
|
|
112
|
+
// echoed characters). Uses raw mode on process.stdin so the OS does not
|
|
113
|
+
// echo the key. Falls back to plain readline if raw mode is unavailable
|
|
114
|
+
// (non-TTY, piped input, etc.).
|
|
115
|
+
export async function readMaskedInput(prompt) {
|
|
116
|
+
const { stdin, stdout } = process;
|
|
117
|
+
stdout.write(prompt);
|
|
118
|
+
|
|
119
|
+
// Non-interactive fallback: read a single line from stdin without masking.
|
|
120
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
let buf = '';
|
|
123
|
+
const onData = (chunk) => {
|
|
124
|
+
buf += chunk.toString();
|
|
125
|
+
const nl = buf.indexOf('\n');
|
|
126
|
+
if (nl !== -1) {
|
|
127
|
+
stdin.removeListener('data', onData);
|
|
128
|
+
resolve(buf.slice(0, nl).replace(/\r$/, ''));
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
stdin.on('data', onData);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return new Promise((resolve) => {
|
|
136
|
+
let buf = '';
|
|
137
|
+
const wasRaw = stdin.isRaw;
|
|
138
|
+
stdin.setRawMode(true);
|
|
139
|
+
stdin.resume();
|
|
140
|
+
stdin.setEncoding('utf8');
|
|
141
|
+
|
|
142
|
+
const cleanup = () => {
|
|
143
|
+
stdin.removeListener('data', onData);
|
|
144
|
+
try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
|
|
145
|
+
stdin.pause();
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const onData = (ch) => {
|
|
149
|
+
// Handle paste: terminals send pasted content as one chunk.
|
|
150
|
+
if (ch.length > 1 && !ch.includes('\r') && !ch.includes('\n') && !ch.includes('\u0003')) {
|
|
151
|
+
buf += ch;
|
|
152
|
+
for (let i = 0; i < ch.length; i++) stdout.write('•');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
for (const c of ch) {
|
|
156
|
+
if (c === '\r' || c === '\n') {
|
|
157
|
+
stdout.write('\n');
|
|
158
|
+
cleanup();
|
|
159
|
+
resolve(buf);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (c === '\u0003') {
|
|
163
|
+
// Ctrl-C
|
|
164
|
+
stdout.write('\n');
|
|
165
|
+
cleanup();
|
|
166
|
+
resolve('');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (c === '\u007f' || c === '\b') {
|
|
170
|
+
if (buf.length > 0) {
|
|
171
|
+
buf = buf.slice(0, -1);
|
|
172
|
+
stdout.write('\b \b');
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (c < ' ') continue; // ignore other control chars
|
|
177
|
+
buf += c;
|
|
178
|
+
stdout.write('•');
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
stdin.on('data', onData);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Top-level interactive flow: read a key, detect the provider, save, report.
|
|
187
|
+
// Returns { ok: true, provider, path } on success, { ok: false, reason } on
|
|
188
|
+
// failure. The caller renders the result to the user.
|
|
189
|
+
//
|
|
190
|
+
// If `rl` is passed (a readline.Interface from readline/promises), we use
|
|
191
|
+
// rl.question for the prompt instead of attaching a raw stdin listener. That
|
|
192
|
+
// path matters for the slash-command case inside the REPL where readline
|
|
193
|
+
// already owns stdin; using rl.question avoids a race where readline eats
|
|
194
|
+
// the key line before our masked reader can see it.
|
|
195
|
+
export async function runInteractiveKeyFlow({ providerHint, stdout, rl } = {}) {
|
|
196
|
+
const out = stdout || process.stdout;
|
|
197
|
+
let rawKey;
|
|
198
|
+
if (rl && typeof rl.question === 'function') {
|
|
199
|
+
// Inside the REPL: read through the active readline instance.
|
|
200
|
+
// Note: this path does not mask echo in the terminal. That is a
|
|
201
|
+
// deliberate trade-off for reliable input under the REPL. For a
|
|
202
|
+
// fully-masked prompt, use `polycode login` from the shell instead.
|
|
203
|
+
rawKey = (await rl.question('Paste your API key (or press Enter to cancel): ')).trim();
|
|
204
|
+
} else {
|
|
205
|
+
rawKey = (await readMaskedInput('Paste your API key (or press Enter to cancel): ')).trim();
|
|
206
|
+
}
|
|
207
|
+
if (!rawKey) {
|
|
208
|
+
return { ok: false, reason: 'cancelled' };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let provider = null;
|
|
212
|
+
if (providerHint) {
|
|
213
|
+
provider = getProviderById(providerHint);
|
|
214
|
+
if (provider && !provider.prefix.test(rawKey)) {
|
|
215
|
+
return { ok: false, reason: `that does not look like a ${provider.name} key` };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!provider) provider = detectProvider(rawKey);
|
|
219
|
+
if (!provider) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
reason: `could not detect provider from key prefix. Expected one of: ${PROVIDERS.map((p) => `${p.name} (${p.envVar})`).join(', ')}`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const result = saveProviderKey(provider.id, rawKey);
|
|
228
|
+
return { ok: true, provider: result.provider, path: result.path };
|
|
229
|
+
} catch (err) {
|
|
230
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
231
|
+
}
|
|
232
|
+
}
|