codiedev 0.9.89 → 0.9.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/captureLog.d.ts +1 -1
- package/dist/codie.js +56 -4
- package/dist/codieLauncher.d.ts +3 -0
- package/dist/codieLauncher.js +16 -1
- package/dist/hook.js +52 -0
- package/dist/launchContext.d.ts +27 -0
- package/dist/launchContext.js +79 -0
- package/dist/promptGuard.d.ts +63 -0
- package/dist/promptGuard.js +261 -0
- package/dist/scrub/promptRules.d.ts +119 -0
- package/dist/scrub/promptRules.js +941 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.js +48 -9
- package/package.json +1 -1
package/dist/captureLog.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "fs";
|
|
|
2
2
|
export declare const CAPTURE_LOG_MAX_BYTES: number;
|
|
3
3
|
export declare const CAPTURE_LOG_PATH: string;
|
|
4
4
|
export interface CaptureLogEntry {
|
|
5
|
-
stage: "capture-finish" | "upload" | "upload-deferred" | "stderr" | "limit" | "self-update";
|
|
5
|
+
stage: "capture-finish" | "upload" | "upload-deferred" | "stderr" | "limit" | "self-update" | "prompt-guard";
|
|
6
6
|
sessionId: string | undefined;
|
|
7
7
|
outcome: string;
|
|
8
8
|
detail?: string;
|
package/dist/codie.js
CHANGED
|
@@ -176,6 +176,57 @@ function installBrandWordmarkUnsafe(runtime) {
|
|
|
176
176
|
(0, child_process_1.spawnSync)("codesign", ["--force", "--sign", "-", runtime], { stdio: "ignore" });
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
|
+
async function runNonInteractive(runtime, runtimeArgs, env) {
|
|
180
|
+
const detached = process.platform !== "win32";
|
|
181
|
+
const child = (0, child_process_1.spawn)(runtime, runtimeArgs, {
|
|
182
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
183
|
+
env,
|
|
184
|
+
detached,
|
|
185
|
+
});
|
|
186
|
+
if (child.stdin)
|
|
187
|
+
process.stdin.pipe(child.stdin);
|
|
188
|
+
const signalChild = (signal) => {
|
|
189
|
+
if (detached && child.pid) {
|
|
190
|
+
try {
|
|
191
|
+
process.kill(-child.pid, signal);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Fall back to the direct child if its process group already exited.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
child.kill(signal);
|
|
199
|
+
};
|
|
200
|
+
const forwardInterrupt = () => signalChild("SIGINT");
|
|
201
|
+
const forwardTermination = () => signalChild("SIGTERM");
|
|
202
|
+
process.once("SIGINT", forwardInterrupt);
|
|
203
|
+
process.once("SIGTERM", forwardTermination);
|
|
204
|
+
return await new Promise((resolve, reject) => {
|
|
205
|
+
child.stdout?.on("data", (chunk) => process.stdout.write(chunk));
|
|
206
|
+
child.stderr?.on("data", (chunk) => {
|
|
207
|
+
// The Codiedev plugin stopped a prompt with a key in it: print its
|
|
208
|
+
// reason plainly instead of the private marker line.
|
|
209
|
+
const text = chunk.toString("utf8");
|
|
210
|
+
if (!text.includes(codieLauncher_1.PROMPT_BLOCK_MARKER))
|
|
211
|
+
return process.stderr.write(chunk);
|
|
212
|
+
process.stderr.write(text.split(/\r?\n/).map((line) => (0, codieLauncher_1.promptBlockLine)(line) ?? line).join("\n"));
|
|
213
|
+
});
|
|
214
|
+
child.once("error", (error) => {
|
|
215
|
+
if (child.stdin)
|
|
216
|
+
process.stdin.unpipe(child.stdin);
|
|
217
|
+
process.removeListener("SIGINT", forwardInterrupt);
|
|
218
|
+
process.removeListener("SIGTERM", forwardTermination);
|
|
219
|
+
reject(error);
|
|
220
|
+
});
|
|
221
|
+
child.once("close", (status) => {
|
|
222
|
+
if (child.stdin)
|
|
223
|
+
process.stdin.unpipe(child.stdin);
|
|
224
|
+
process.removeListener("SIGINT", forwardInterrupt);
|
|
225
|
+
process.removeListener("SIGTERM", forwardTermination);
|
|
226
|
+
resolve(status);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
179
230
|
async function main() {
|
|
180
231
|
(0, codieRuntime_1.assertCodieNodeVersion)(process.versions.node);
|
|
181
232
|
if (!process.env.CODIEDEV_GATEWAY_KEY && !process.env.CODIEDEV_TOKEN) {
|
|
@@ -303,6 +354,10 @@ async function main() {
|
|
|
303
354
|
};
|
|
304
355
|
const runtimeArgs = (0, codieLauncher_1.withCodieWorkingDirectory)(parsed.args, process.cwd());
|
|
305
356
|
const nonInteractiveRun = runtimeArgs[0] === "run";
|
|
357
|
+
// A run has no TUI to show a stopped prompt; the plugin hands the reason
|
|
358
|
+
// back on stderr instead (see runNonInteractive).
|
|
359
|
+
if (nonInteractiveRun)
|
|
360
|
+
env.CODIEDEV_PROMPT_BLOCK_OUTPUT = "marker";
|
|
306
361
|
if (nonInteractiveRun) {
|
|
307
362
|
// The TUI paints its own live status line from the same endpoint. A
|
|
308
363
|
// non-interactive run has no TUI, so the budget arrives as two printed
|
|
@@ -315,10 +370,7 @@ async function main() {
|
|
|
315
370
|
if (line)
|
|
316
371
|
console.error(line);
|
|
317
372
|
}
|
|
318
|
-
|
|
319
|
-
if (result.error)
|
|
320
|
-
throw result.error;
|
|
321
|
-
process.exitCode = result.status === null ? 1 : result.status;
|
|
373
|
+
process.exitCode = (await runNonInteractive(runtime, runtimeArgs, env)) ?? 1;
|
|
322
374
|
if (reportCost) {
|
|
323
375
|
// The gateway books a request's cost after it has answered, so a poll
|
|
324
376
|
// fired the instant the run closes can miss the last one.
|
package/dist/codieLauncher.d.ts
CHANGED
|
@@ -89,3 +89,6 @@ export declare function parseCodieArgs(argv: string[]): {
|
|
|
89
89
|
overrides: Record<string, string>;
|
|
90
90
|
};
|
|
91
91
|
export declare function validateCodieArgs(args: string[], catalog: CatalogModel[]): void;
|
|
92
|
+
export declare const PROMPT_BLOCK_MARKER = "__CODIEDEV_PROMPT_BLOCKED__";
|
|
93
|
+
/** The reason on a marker line, or null for any other line. */
|
|
94
|
+
export declare function promptBlockLine(line: string): string | null;
|
package/dist/codieLauncher.js
CHANGED
|
@@ -42,7 +42,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
42
42
|
};
|
|
43
43
|
})();
|
|
44
44
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
-
exports.HIDDEN_WORDMARK = exports.CODIEDEV_WORDMARK = exports.LEGACY_CODIEDEV_WORDMARK = exports.STOCK_WORDMARK = exports.BRAND_PLUGIN_SPEC = exports.USAGE_MODULE_PLACEHOLDER = void 0;
|
|
45
|
+
exports.PROMPT_BLOCK_MARKER = exports.HIDDEN_WORDMARK = exports.CODIEDEV_WORDMARK = exports.LEGACY_CODIEDEV_WORDMARK = exports.STOCK_WORDMARK = exports.BRAND_PLUGIN_SPEC = exports.USAGE_MODULE_PLACEHOLDER = void 0;
|
|
46
46
|
exports.usableModelCost = usableModelCost;
|
|
47
47
|
exports.usableModelLimit = usableModelLimit;
|
|
48
48
|
exports.withCodieWorkingDirectory = withCodieWorkingDirectory;
|
|
@@ -56,6 +56,7 @@ exports.resolveCapturePlugin = resolveCapturePlugin;
|
|
|
56
56
|
exports.fetchApprovedCatalog = fetchApprovedCatalog;
|
|
57
57
|
exports.parseCodieArgs = parseCodieArgs;
|
|
58
58
|
exports.validateCodieArgs = validateCodieArgs;
|
|
59
|
+
exports.promptBlockLine = promptBlockLine;
|
|
59
60
|
const path = __importStar(require("path"));
|
|
60
61
|
const url_1 = require("url");
|
|
61
62
|
const harnessDirs_1 = require("./harnessDirs");
|
|
@@ -461,3 +462,17 @@ function validateCodieArgs(args, catalog) {
|
|
|
461
462
|
}
|
|
462
463
|
}
|
|
463
464
|
}
|
|
465
|
+
exports.PROMPT_BLOCK_MARKER = "__CODIEDEV_PROMPT_BLOCKED__";
|
|
466
|
+
/** The reason on a marker line, or null for any other line. */
|
|
467
|
+
function promptBlockLine(line) {
|
|
468
|
+
const at = line.indexOf(exports.PROMPT_BLOCK_MARKER);
|
|
469
|
+
if (at < 0)
|
|
470
|
+
return null;
|
|
471
|
+
try {
|
|
472
|
+
const reason = JSON.parse(line.slice(at + exports.PROMPT_BLOCK_MARKER.length)).reason;
|
|
473
|
+
return typeof reason === "string" && reason.trim() ? `\n${reason.trim()}` : null;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
package/dist/hook.js
CHANGED
|
@@ -64,6 +64,7 @@ const copilotHooks_1 = require("./copilotHooks");
|
|
|
64
64
|
const piHooks_1 = require("./piHooks");
|
|
65
65
|
const cliSession_1 = require("./cliSession");
|
|
66
66
|
const utils_2 = require("./utils");
|
|
67
|
+
const promptGuard_1 = require("./promptGuard");
|
|
67
68
|
// Pure builder for the discovery additionalContext. Returns null when there are
|
|
68
69
|
// no matches. The suggested `codiedev pull` command carries the EXACT top key
|
|
69
70
|
// (e.g. "pr-push.md") rather than a "<key>" placeholder — otherwise agents
|
|
@@ -597,6 +598,48 @@ async function main() {
|
|
|
597
598
|
catch {
|
|
598
599
|
process.exit(0);
|
|
599
600
|
}
|
|
601
|
+
// Privacy exclusions suppress every Codiedev upload.
|
|
602
|
+
const captureIgnored = (0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput);
|
|
603
|
+
// Background refresh of the company's "stop prompts with a key" setting.
|
|
604
|
+
if (process.argv[2] === "prompt-guard-refresh") {
|
|
605
|
+
await (0, cliSession_1.ensureFreshToken)({ configPath: utils_2.CONFIG_PATH }).catch(() => undefined);
|
|
606
|
+
await (0, promptGuard_1.refreshPromptGuardPolicy)((0, utils_1.readConfig)());
|
|
607
|
+
process.exit(0);
|
|
608
|
+
}
|
|
609
|
+
// Stop a prompt with a key in it, when the company turned that on. Runs
|
|
610
|
+
// before discovery (which would send the prompt to the backend) and even
|
|
611
|
+
// where capture is excluded: it's local and never uploads the prompt.
|
|
612
|
+
const PROMPT_HOOKS = {
|
|
613
|
+
discover: "claude-code",
|
|
614
|
+
"discover-codex": "codex",
|
|
615
|
+
"discover-codie": "codie-harness",
|
|
616
|
+
"discover-copilot": "copilot",
|
|
617
|
+
"discover-pi": "pi",
|
|
618
|
+
"prompt-guard-cursor": "cursor",
|
|
619
|
+
};
|
|
620
|
+
const promptHarness = PROMPT_HOOKS[process.argv[2] ?? ""];
|
|
621
|
+
if (promptHarness) {
|
|
622
|
+
const guard = (0, promptGuard_1.checkPrompt)(hookInput.prompt, promptHarness, (0, promptGuard_1.readPromptGuardPolicy)());
|
|
623
|
+
// An ignored session makes no Codiedev request, not even this refresh.
|
|
624
|
+
if (!captureIgnored)
|
|
625
|
+
(0, promptGuard_1.maybeSpawnPromptGuardRefresh)({ connected: Boolean((0, utils_1.readConfig)()) });
|
|
626
|
+
if (guard.blocked) {
|
|
627
|
+
(0, captureLog_1.appendCaptureLog)({
|
|
628
|
+
stage: "prompt-guard",
|
|
629
|
+
sessionId: hookInput.session_id ?? "unknown",
|
|
630
|
+
outcome: "ok",
|
|
631
|
+
detail: `blocked harness=${promptHarness} rule=${guard.key.rule}`,
|
|
632
|
+
});
|
|
633
|
+
process.stdout.write(JSON.stringify(guard.response));
|
|
634
|
+
if (!captureIgnored)
|
|
635
|
+
await (0, promptGuard_1.reportPromptBlocked)((0, utils_1.readConfig)(), guard.key.rule, promptHarness);
|
|
636
|
+
process.exit(0);
|
|
637
|
+
}
|
|
638
|
+
if (promptHarness === "cursor") {
|
|
639
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
640
|
+
process.exit(0);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
600
643
|
// Local privacy boundary: every stdin-driven hook mode passes through this
|
|
601
644
|
// check BEFORE prompt discovery, limit-event tails, SessionStart pings,
|
|
602
645
|
// backup, self-heal, transcript reads, or detached dispatch. Ignored means
|
|
@@ -642,6 +685,11 @@ async function main() {
|
|
|
642
685
|
// starts it (hourly throttle inside).
|
|
643
686
|
(0, localScrub_1.maybeSpawnLocalScrub)({ connected: Boolean((0, utils_1.readConfig)()), cwd: hookInput.cwd });
|
|
644
687
|
}
|
|
688
|
+
// Scripted sessions (`claude -p`, the Agent SDK, `codex exec`, `codie run`,
|
|
689
|
+
// `pi -p`) get no suggestion: they run a fixed job and ignore it, and a
|
|
690
|
+
// pipeline fires this hook hundreds of times a day.
|
|
691
|
+
if (promptHarness && (0, launchContext_1.isScriptedPrompt)(promptHarness, hookInput))
|
|
692
|
+
return;
|
|
645
693
|
if (process.argv[2] !== "discover-copilot")
|
|
646
694
|
await runDiscover(hookInput.prompt);
|
|
647
695
|
return;
|
|
@@ -665,6 +713,8 @@ async function main() {
|
|
|
665
713
|
(0, sync_1.maybeSpawnPackSync)({ connected: Boolean((0, utils_1.readConfig)()), cli: path.join(__dirname, "cli.js") });
|
|
666
714
|
// Local session scrub of past sessions on this machine (hourly, detached).
|
|
667
715
|
(0, localScrub_1.maybeSpawnLocalScrub)({ connected: Boolean((0, utils_1.readConfig)()), cwd: hookInput.cwd });
|
|
716
|
+
// Keep "stop prompts with a key" current before the first prompt.
|
|
717
|
+
(0, promptGuard_1.maybeSpawnPromptGuardRefresh)({ connected: Boolean((0, utils_1.readConfig)()) });
|
|
668
718
|
const packNag = process.argv[2] === "session-start-opencode" || process.argv[2] === "session-start-pi" ? null : (0, sync_1.packNagLine)();
|
|
669
719
|
// Claude Code adds SessionStart stdout to the session context.
|
|
670
720
|
if (packNag && process.argv[2] === "session-start")
|
|
@@ -716,6 +766,8 @@ async function main() {
|
|
|
716
766
|
}
|
|
717
767
|
if (process.argv[2] === "capture-codex")
|
|
718
768
|
(0, utils_1.ensureCodexLimitHookInstalled)();
|
|
769
|
+
if (/-opencode(?:-final)?$/.test(process.argv[2] ?? ""))
|
|
770
|
+
(0, utils_1.ensureOpenCodePluginCurrent)();
|
|
719
771
|
const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
|
|
720
772
|
// Read here, in the process the harness spawned: the detached capture child
|
|
721
773
|
// is reparented once this exits and can no longer see who launched it.
|
package/dist/launchContext.d.ts
CHANGED
|
@@ -43,4 +43,31 @@ export declare function hookLaunch(hookCommand: string | undefined, input: {
|
|
|
43
43
|
copilotClientName?: string;
|
|
44
44
|
detectCopilot?: () => string | undefined;
|
|
45
45
|
}): string | undefined;
|
|
46
|
+
/** Codex writes `session_meta` as the first line of its rollout file; its
|
|
47
|
+
* `source` is "cli", "vscode", "exec" (`codex exec`), "mcp", or an object
|
|
48
|
+
* for a sub-agent. A first line longer than `head` reads as unknown. */
|
|
49
|
+
export declare function codexLaunchFromRolloutHead(head: string): string | undefined;
|
|
50
|
+
export type PromptHookHarness = "claude-code" | "codex" | "codie-harness" | "copilot" | "pi";
|
|
51
|
+
/**
|
|
52
|
+
* True when a prompt hook is running inside a session a script started
|
|
53
|
+
* (`claude -p`, the Agent SDK, `codex exec`, `codie run`, `pi -p`…). Such a
|
|
54
|
+
* session already knows what it is doing, so team-skill suggestions are
|
|
55
|
+
* skipped for it: the lookup would send every scripted prompt to the backend
|
|
56
|
+
* for advice nothing reads.
|
|
57
|
+
*
|
|
58
|
+
* Where the answer comes from, per harness:
|
|
59
|
+
* - Codie and pi pass `launch` in the hook input (their plugin knows its argv).
|
|
60
|
+
* - Claude Code names its entrypoint in the hook's environment: "cli" for a
|
|
61
|
+
* person, "sdk-*" for a script.
|
|
62
|
+
* - Codex records it on the first line of the rollout file the hook is given.
|
|
63
|
+
* Unknown or absent values are treated as a person, so nobody loses
|
|
64
|
+
* suggestions by mistake.
|
|
65
|
+
*/
|
|
66
|
+
export declare function isScriptedPrompt(harness: PromptHookHarness, input: {
|
|
67
|
+
launch?: unknown;
|
|
68
|
+
transcript_path?: unknown;
|
|
69
|
+
}, deps?: {
|
|
70
|
+
env?: Record<string, string | undefined>;
|
|
71
|
+
readHead?: (file: string) => string;
|
|
72
|
+
}): boolean;
|
|
46
73
|
export {};
|
package/dist/launchContext.js
CHANGED
|
@@ -7,7 +7,10 @@ exports.windowsAncestors = windowsAncestors;
|
|
|
7
7
|
exports.detectCursorLaunch = detectCursorLaunch;
|
|
8
8
|
exports.detectCopilotLaunch = detectCopilotLaunch;
|
|
9
9
|
exports.hookLaunch = hookLaunch;
|
|
10
|
+
exports.codexLaunchFromRolloutHead = codexLaunchFromRolloutHead;
|
|
11
|
+
exports.isScriptedPrompt = isScriptedPrompt;
|
|
10
12
|
const node_child_process_1 = require("node:child_process");
|
|
13
|
+
const node_fs_1 = require("node:fs");
|
|
11
14
|
/**
|
|
12
15
|
* How a harness that records nothing about its own launch was started.
|
|
13
16
|
* Claude Code (`entrypoint`) and Codex (`session_meta.source`) stamp this in
|
|
@@ -198,3 +201,79 @@ function hookLaunch(hookCommand, input, deps = {}) {
|
|
|
198
201
|
}
|
|
199
202
|
return undefined;
|
|
200
203
|
}
|
|
204
|
+
/** Launch values a script drives. Mirrors isScriptedEntrypoint in
|
|
205
|
+
* convex/lib/sessionAutomation.ts, plus pi's print/json/rpc modes, which
|
|
206
|
+
* docs/dev-users/install.md also counts as scripted. */
|
|
207
|
+
const SCRIPTED_LAUNCHES = new Set([
|
|
208
|
+
"codex:exec", "codex:mcp",
|
|
209
|
+
"codie:run", "codie:serve", "codie:acp",
|
|
210
|
+
"cursor:print", "copilot:print",
|
|
211
|
+
"pi:print", "pi:json", "pi:rpc",
|
|
212
|
+
]);
|
|
213
|
+
const isScriptedLaunch = (launch) => launch.startsWith("sdk") || SCRIPTED_LAUNCHES.has(launch);
|
|
214
|
+
/** Codex writes `session_meta` as the first line of its rollout file; its
|
|
215
|
+
* `source` is "cli", "vscode", "exec" (`codex exec`), "mcp", or an object
|
|
216
|
+
* for a sub-agent. A first line longer than `head` reads as unknown. */
|
|
217
|
+
function codexLaunchFromRolloutHead(head) {
|
|
218
|
+
const end = head.indexOf("\n");
|
|
219
|
+
if (end < 0)
|
|
220
|
+
return undefined;
|
|
221
|
+
try {
|
|
222
|
+
const first = JSON.parse(head.slice(0, end));
|
|
223
|
+
if (first?.type !== "session_meta")
|
|
224
|
+
return undefined;
|
|
225
|
+
const src = first.payload?.source;
|
|
226
|
+
if (typeof src === "string")
|
|
227
|
+
return VALUE.test(`codex:${src}`) ? `codex:${src}` : undefined;
|
|
228
|
+
if (src && typeof src === "object")
|
|
229
|
+
return `codex:${Object.keys(src)[0] ?? "other"}`;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// not JSON: unknown
|
|
233
|
+
}
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
/** The first `bytes` of a file, or "" when it can't be read. */
|
|
237
|
+
function readHead(file, bytes = 256 * 1024) {
|
|
238
|
+
let fd;
|
|
239
|
+
try {
|
|
240
|
+
fd = (0, node_fs_1.openSync)(file, "r");
|
|
241
|
+
const buf = Buffer.alloc(bytes);
|
|
242
|
+
return buf.toString("utf8", 0, (0, node_fs_1.readSync)(fd, buf, 0, bytes, 0));
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return "";
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
if (fd !== undefined)
|
|
249
|
+
(0, node_fs_1.closeSync)(fd);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* True when a prompt hook is running inside a session a script started
|
|
254
|
+
* (`claude -p`, the Agent SDK, `codex exec`, `codie run`, `pi -p`…). Such a
|
|
255
|
+
* session already knows what it is doing, so team-skill suggestions are
|
|
256
|
+
* skipped for it: the lookup would send every scripted prompt to the backend
|
|
257
|
+
* for advice nothing reads.
|
|
258
|
+
*
|
|
259
|
+
* Where the answer comes from, per harness:
|
|
260
|
+
* - Codie and pi pass `launch` in the hook input (their plugin knows its argv).
|
|
261
|
+
* - Claude Code names its entrypoint in the hook's environment: "cli" for a
|
|
262
|
+
* person, "sdk-*" for a script.
|
|
263
|
+
* - Codex records it on the first line of the rollout file the hook is given.
|
|
264
|
+
* Unknown or absent values are treated as a person, so nobody loses
|
|
265
|
+
* suggestions by mistake.
|
|
266
|
+
*/
|
|
267
|
+
function isScriptedPrompt(harness, input, deps = {}) {
|
|
268
|
+
if (typeof input.launch === "string" && VALUE.test(input.launch))
|
|
269
|
+
return isScriptedLaunch(input.launch);
|
|
270
|
+
if (harness === "claude-code") {
|
|
271
|
+
const entrypoint = (deps.env ?? process.env).CLAUDE_CODE_ENTRYPOINT;
|
|
272
|
+
return typeof entrypoint === "string" && entrypoint.startsWith("sdk");
|
|
273
|
+
}
|
|
274
|
+
if (harness === "codex" && typeof input.transcript_path === "string" && input.transcript_path) {
|
|
275
|
+
const launch = codexLaunchFromRolloutHead((deps.readHead ?? readHead)(input.transcript_path));
|
|
276
|
+
return launch !== undefined && isScriptedLaunch(launch);
|
|
277
|
+
}
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "child_process";
|
|
2
|
+
import { type ExtraRule } from "./scrub/promptRules";
|
|
3
|
+
export type PromptHarness = "claude-code" | "codex" | "cursor" | "codie-harness" | "copilot" | "pi";
|
|
4
|
+
export interface TokenFormat {
|
|
5
|
+
name: string;
|
|
6
|
+
source: string;
|
|
7
|
+
keyword?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PromptGuardPolicy {
|
|
10
|
+
blockPrompts: boolean;
|
|
11
|
+
formats: TokenFormat[];
|
|
12
|
+
fetchedAt: number;
|
|
13
|
+
}
|
|
14
|
+
export declare const REFRESH_MS: number;
|
|
15
|
+
export interface PromptKey {
|
|
16
|
+
rule: string;
|
|
17
|
+
label: string;
|
|
18
|
+
hint: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function keyLabel(rule: string): string;
|
|
21
|
+
/** The company's own token formats as scrub rules; a bad one is skipped. */
|
|
22
|
+
export declare function formatRules(formats: readonly TokenFormat[]): ExtraRule[];
|
|
23
|
+
/** The first real key in a prompt, or null. */
|
|
24
|
+
export declare function findPromptKey(prompt: string, formats?: readonly TokenFormat[], now?: number): PromptKey | null;
|
|
25
|
+
/** What the person sees. Names the kind of key and its last four, says
|
|
26
|
+
* nothing was sent, and how to do it instead. */
|
|
27
|
+
export declare function blockMessage(key: PromptKey): string;
|
|
28
|
+
/** The hook's stdout for each harness when it stops a prompt. */
|
|
29
|
+
export declare function blockResponse(harness: PromptHarness, message: string): Record<string, unknown>;
|
|
30
|
+
export declare function readPromptGuardPolicy(stateDir?: string): PromptGuardPolicy | null;
|
|
31
|
+
export declare function writePromptGuardPolicy(policy: PromptGuardPolicy, stateDir?: string): void;
|
|
32
|
+
interface GuardConfig {
|
|
33
|
+
token?: string;
|
|
34
|
+
backendUrl?: string;
|
|
35
|
+
}
|
|
36
|
+
/** Ask the server for the company's setting and cache it. On any failure the
|
|
37
|
+
* last cached answer stays (and its age makes the next hook try again). */
|
|
38
|
+
export declare function refreshPromptGuardPolicy(config: GuardConfig | null, fetchImpl?: typeof fetch, stateDir?: string, now?: number): Promise<PromptGuardPolicy | null>;
|
|
39
|
+
/** Start a background refresh when the cached setting is missing or stale.
|
|
40
|
+
* Stamps the cache's age first so a burst of hooks starts only one. */
|
|
41
|
+
export declare function maybeSpawnPromptGuardRefresh(opts: {
|
|
42
|
+
connected: boolean;
|
|
43
|
+
stateDir?: string;
|
|
44
|
+
now?: number;
|
|
45
|
+
hookEntry?: string;
|
|
46
|
+
spawn?: typeof nodeSpawn;
|
|
47
|
+
force?: boolean;
|
|
48
|
+
}): "spawned" | "fresh" | "disabled" | "error";
|
|
49
|
+
export interface PromptCheck {
|
|
50
|
+
blocked: false;
|
|
51
|
+
}
|
|
52
|
+
export interface PromptBlocked {
|
|
53
|
+
blocked: true;
|
|
54
|
+
key: PromptKey;
|
|
55
|
+
message: string;
|
|
56
|
+
response: Record<string, unknown>;
|
|
57
|
+
}
|
|
58
|
+
/** Check one prompt against the cached setting. Never throws. */
|
|
59
|
+
export declare function checkPrompt(prompt: string | undefined, harness: PromptHarness, policy: PromptGuardPolicy | null, now?: number): PromptCheck | PromptBlocked;
|
|
60
|
+
/** Tell the server a prompt was stopped: the rule and harness only. Detached
|
|
61
|
+
* and best-effort; a slow network never holds the prompt hook. */
|
|
62
|
+
export declare function reportPromptBlocked(config: GuardConfig | null, rule: string, harness: PromptHarness, fetchImpl?: typeof fetch): Promise<void>;
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Stop a prompt that has a key in it before it reaches the model. Off unless
|
|
3
|
+
// a company admin turns it on (Team Security → Settings → Stop prompts with a
|
|
4
|
+
// key). Everything happens on the laptop: the prompt and the key are never
|
|
5
|
+
// sent anywhere; when a prompt is stopped the server only hears which kind of
|
|
6
|
+
// key it was and which harness, for the team's activity feed.
|
|
7
|
+
//
|
|
8
|
+
// Precision over recall: only a provider's own key format (sk-ant-…, ghp_…,
|
|
9
|
+
// AKIA…), a private key block, a password in a URL, or one of the company's
|
|
10
|
+
// own token formats stops a prompt, and only when the value looks real (not a
|
|
11
|
+
// placeholder, sample or test value: scrub/rules notWorthRotating). Generic
|
|
12
|
+
// shapes (KEY=value, Bearer …, JWTs) never stop one; the scrub still removes
|
|
13
|
+
// them from stored sessions.
|
|
14
|
+
//
|
|
15
|
+
// The hook must answer fast and offline, so it reads a cached copy of the
|
|
16
|
+
// company's setting. The cache is refreshed in the background (at session
|
|
17
|
+
// start, and from the prompt hook when it is over REFRESH_MS old).
|
|
18
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
19
|
+
if (k2 === undefined) k2 = k;
|
|
20
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
21
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
22
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
23
|
+
}
|
|
24
|
+
Object.defineProperty(o, k2, desc);
|
|
25
|
+
}) : (function(o, m, k, k2) {
|
|
26
|
+
if (k2 === undefined) k2 = k;
|
|
27
|
+
o[k2] = m[k];
|
|
28
|
+
}));
|
|
29
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
30
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
31
|
+
}) : function(o, v) {
|
|
32
|
+
o["default"] = v;
|
|
33
|
+
});
|
|
34
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
35
|
+
var ownKeys = function(o) {
|
|
36
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
37
|
+
var ar = [];
|
|
38
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
39
|
+
return ar;
|
|
40
|
+
};
|
|
41
|
+
return ownKeys(o);
|
|
42
|
+
};
|
|
43
|
+
return function (mod) {
|
|
44
|
+
if (mod && mod.__esModule) return mod;
|
|
45
|
+
var result = {};
|
|
46
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
47
|
+
__setModuleDefault(result, mod);
|
|
48
|
+
return result;
|
|
49
|
+
};
|
|
50
|
+
})();
|
|
51
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
52
|
+
exports.REFRESH_MS = void 0;
|
|
53
|
+
exports.keyLabel = keyLabel;
|
|
54
|
+
exports.formatRules = formatRules;
|
|
55
|
+
exports.findPromptKey = findPromptKey;
|
|
56
|
+
exports.blockMessage = blockMessage;
|
|
57
|
+
exports.blockResponse = blockResponse;
|
|
58
|
+
exports.readPromptGuardPolicy = readPromptGuardPolicy;
|
|
59
|
+
exports.writePromptGuardPolicy = writePromptGuardPolicy;
|
|
60
|
+
exports.refreshPromptGuardPolicy = refreshPromptGuardPolicy;
|
|
61
|
+
exports.maybeSpawnPromptGuardRefresh = maybeSpawnPromptGuardRefresh;
|
|
62
|
+
exports.checkPrompt = checkPrompt;
|
|
63
|
+
exports.reportPromptBlocked = reportPromptBlocked;
|
|
64
|
+
const fs = __importStar(require("fs"));
|
|
65
|
+
const path = __importStar(require("path"));
|
|
66
|
+
const child_process_1 = require("child_process");
|
|
67
|
+
const configDir_1 = require("./configDir");
|
|
68
|
+
const promptRules_1 = require("./scrub/promptRules");
|
|
69
|
+
const scrubProviderRules_1 = require("./scrub/scrubProviderRules");
|
|
70
|
+
exports.REFRESH_MS = 10 * 60_000;
|
|
71
|
+
const CACHE_FILE = "prompt-guard.json";
|
|
72
|
+
const FETCH_TIMEOUT_MS = 8_000;
|
|
73
|
+
const LABELS = {
|
|
74
|
+
...scrubProviderRules_1.PROVIDER_RULE_LABELS,
|
|
75
|
+
"supabase-token": "Supabase token", "groq-key": "Groq key", "xai-key": "xAI key", "replicate-token": "Replicate token",
|
|
76
|
+
"aws-access-key": "AWS key", "openai-key": "OpenAI key", "anthropic-key": "Anthropic key",
|
|
77
|
+
"github-token": "GitHub token", "gitlab-token": "GitLab token", "stripe-key": "Stripe key",
|
|
78
|
+
"slack-token": "Slack token", "slack-webhook": "Slack webhook", "google-api-key": "Google API key",
|
|
79
|
+
"pem-private-key": "private key", "convex-deploy-key": "Convex deploy key", "linear-key": "Linear key",
|
|
80
|
+
"codiedev-key": "Codiedev key", "fireworks-key": "Fireworks key", "npm-token": "npm token",
|
|
81
|
+
"sendgrid-key": "SendGrid key", "twilio-key": "Twilio key", "azure-storage-key": "Azure storage key",
|
|
82
|
+
"azure-sas": "Azure SAS token", "url-password": "password in a URL",
|
|
83
|
+
"curl-auth-header": "token in a curl command", "curl-auth-user": "password in a curl command",
|
|
84
|
+
};
|
|
85
|
+
/** Keys that are real but not worth stopping a prompt for: Stripe test-mode
|
|
86
|
+
* keys (sandbox data only), and a Firebase web config's apiKey (public by
|
|
87
|
+
* design: it ships in every Firebase web app). A Google key anywhere else
|
|
88
|
+
* (Gemini, Maps server keys) still stops the prompt. */
|
|
89
|
+
function harmlessHere(rule, value, prompt) {
|
|
90
|
+
if (rule === "stripe-key" && /^[sr]k_test_/.test(value))
|
|
91
|
+
return true;
|
|
92
|
+
if (rule === "google-api-key" && /firebaseapp\.com|authDomain|firebaseConfig/i.test(prompt))
|
|
93
|
+
return true;
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
function keyLabel(rule) {
|
|
97
|
+
if (rule.startsWith("custom:"))
|
|
98
|
+
return rule.slice("custom:".length);
|
|
99
|
+
return LABELS[rule] ?? rule.replace(/-/g, " ");
|
|
100
|
+
}
|
|
101
|
+
/** The company's own token formats as scrub rules; a bad one is skipped. */
|
|
102
|
+
function formatRules(formats) {
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const f of formats) {
|
|
105
|
+
try {
|
|
106
|
+
out.push({ name: `custom:${f.name}`, re: new RegExp(`(?<![A-Za-z0-9])${f.source}(?![A-Za-z0-9_-])`, "g"), ...(f.keyword ? { keywords: [f.keyword] } : {}) });
|
|
107
|
+
}
|
|
108
|
+
catch { /* skip */ }
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
/** The first real key in a prompt, or null. */
|
|
113
|
+
function findPromptKey(prompt, formats = [], now = Date.now()) {
|
|
114
|
+
if (!prompt || prompt.length > 2_000_000)
|
|
115
|
+
return null;
|
|
116
|
+
const { redactions } = (0, promptRules_1.scrubSecrets)(prompt, { extraRules: formatRules(formats) });
|
|
117
|
+
for (const r of redactions) {
|
|
118
|
+
if (promptRules_1.GENERIC_RULES.has(r.rule))
|
|
119
|
+
continue;
|
|
120
|
+
const value = prompt.slice(r.start, r.end);
|
|
121
|
+
if ((0, promptRules_1.notWorthRotating)({ rule: r.rule, value }, now))
|
|
122
|
+
continue;
|
|
123
|
+
if (harmlessHere(r.rule, value, prompt))
|
|
124
|
+
continue;
|
|
125
|
+
return { rule: r.rule, label: keyLabel(r.rule), hint: (0, promptRules_1.hintOf)(r.rule, value) };
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
/** What the person sees. Names the kind of key and its last four, says
|
|
130
|
+
* nothing was sent, and how to do it instead. */
|
|
131
|
+
function blockMessage(key) {
|
|
132
|
+
const article = /^[aeiou]/i.test(key.label) ? "an" : "a";
|
|
133
|
+
return `Codiedev stopped this prompt: it has ${article} ${key.label} in it (…${key.hint}). ` +
|
|
134
|
+
"Nothing was sent to the model. Put the key in an environment variable or .env file and refer to it by name " +
|
|
135
|
+
"(for example $API_KEY), then send the prompt again. Your team turned on this check.";
|
|
136
|
+
}
|
|
137
|
+
/** The hook's stdout for each harness when it stops a prompt. */
|
|
138
|
+
function blockResponse(harness, message) {
|
|
139
|
+
// Cursor: beforeSubmitPrompt. Everyone else that reads stdout (Claude
|
|
140
|
+
// Code, Codex, Copilot's userPromptSubmitted, and our Codie / pi adapters)
|
|
141
|
+
// takes the Claude-style decision.
|
|
142
|
+
if (harness === "cursor")
|
|
143
|
+
return { continue: false, user_message: message };
|
|
144
|
+
return { decision: "block", reason: message };
|
|
145
|
+
}
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// The company's setting, cached on the laptop
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
function cachePath(stateDir) {
|
|
150
|
+
return path.join(stateDir, CACHE_FILE);
|
|
151
|
+
}
|
|
152
|
+
function readPromptGuardPolicy(stateDir = (0, configDir_1.codiedevStateDir)()) {
|
|
153
|
+
try {
|
|
154
|
+
const v = JSON.parse(fs.readFileSync(cachePath(stateDir), "utf8"));
|
|
155
|
+
if (typeof v.fetchedAt !== "number")
|
|
156
|
+
return null;
|
|
157
|
+
return {
|
|
158
|
+
blockPrompts: v.blockPrompts === true,
|
|
159
|
+
formats: Array.isArray(v.formats) ? v.formats.filter((f) => f && typeof f.name === "string" && typeof f.source === "string").slice(0, 50) : [],
|
|
160
|
+
fetchedAt: v.fetchedAt,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function writePromptGuardPolicy(policy, stateDir = (0, configDir_1.codiedevStateDir)()) {
|
|
168
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
169
|
+
const file = cachePath(stateDir);
|
|
170
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
171
|
+
fs.writeFileSync(tmp, JSON.stringify(policy), { mode: 0o600 });
|
|
172
|
+
fs.renameSync(tmp, file);
|
|
173
|
+
}
|
|
174
|
+
/** Ask the server for the company's setting and cache it. On any failure the
|
|
175
|
+
* last cached answer stays (and its age makes the next hook try again). */
|
|
176
|
+
async function refreshPromptGuardPolicy(config, fetchImpl = fetch, stateDir = (0, configDir_1.codiedevStateDir)(), now = Date.now()) {
|
|
177
|
+
if (!config?.token || !config.backendUrl)
|
|
178
|
+
return null;
|
|
179
|
+
try {
|
|
180
|
+
const res = await fetchImpl(`${config.backendUrl}/api/cli/promptGuard`, {
|
|
181
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
182
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
183
|
+
});
|
|
184
|
+
if (!res.ok)
|
|
185
|
+
return null;
|
|
186
|
+
const body = (await res.json());
|
|
187
|
+
const formats = Array.isArray(body.formats)
|
|
188
|
+
? body.formats.filter((f) => f && typeof f.name === "string" && typeof f.source === "string" && f.source.length <= 300).slice(0, 50)
|
|
189
|
+
: [];
|
|
190
|
+
const policy = { blockPrompts: body.blockPrompts === true, formats, fetchedAt: now };
|
|
191
|
+
writePromptGuardPolicy(policy, stateDir);
|
|
192
|
+
return policy;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Start a background refresh when the cached setting is missing or stale.
|
|
199
|
+
* Stamps the cache's age first so a burst of hooks starts only one. */
|
|
200
|
+
function maybeSpawnPromptGuardRefresh(opts) {
|
|
201
|
+
try {
|
|
202
|
+
if (!opts.connected || process.env.CODIEDEV_PROMPT_GUARD_CHILD)
|
|
203
|
+
return "disabled";
|
|
204
|
+
const stateDir = opts.stateDir ?? (0, configDir_1.codiedevStateDir)();
|
|
205
|
+
const now = opts.now ?? Date.now();
|
|
206
|
+
const stamp = path.join(stateDir, "prompt-guard.refresh");
|
|
207
|
+
if (!opts.force) {
|
|
208
|
+
const cached = readPromptGuardPolicy(stateDir);
|
|
209
|
+
if (cached && now - cached.fetchedAt < exports.REFRESH_MS && cached.fetchedAt <= now)
|
|
210
|
+
return "fresh";
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const last = fs.statSync(stamp).mtimeMs;
|
|
214
|
+
if (now - last < 60_000 && last <= now)
|
|
215
|
+
return "fresh"; // one in flight
|
|
216
|
+
}
|
|
217
|
+
catch { /* never */ }
|
|
218
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
219
|
+
fs.writeFileSync(stamp, String(now));
|
|
220
|
+
const t = new Date(now);
|
|
221
|
+
fs.utimesSync(stamp, t, t);
|
|
222
|
+
const child = (opts.spawn ?? child_process_1.spawn)(process.execPath, [opts.hookEntry ?? path.join(__dirname, "hook.js"), "prompt-guard-refresh"], {
|
|
223
|
+
detached: true,
|
|
224
|
+
stdio: "ignore",
|
|
225
|
+
env: { ...process.env, CODIEDEV_PROMPT_GUARD_CHILD: "1" },
|
|
226
|
+
});
|
|
227
|
+
child.on?.("error", () => { });
|
|
228
|
+
child.unref?.();
|
|
229
|
+
return "spawned";
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return "error";
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Check one prompt against the cached setting. Never throws. */
|
|
236
|
+
function checkPrompt(prompt, harness, policy, now = Date.now()) {
|
|
237
|
+
try {
|
|
238
|
+
if (!policy?.blockPrompts || !prompt)
|
|
239
|
+
return { blocked: false };
|
|
240
|
+
const key = findPromptKey(prompt, policy.formats, now);
|
|
241
|
+
if (!key)
|
|
242
|
+
return { blocked: false };
|
|
243
|
+
const message = blockMessage(key);
|
|
244
|
+
return { blocked: true, key, message, response: blockResponse(harness, message) };
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return { blocked: false };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** Tell the server a prompt was stopped: the rule and harness only. Detached
|
|
251
|
+
* and best-effort; a slow network never holds the prompt hook. */
|
|
252
|
+
function reportPromptBlocked(config, rule, harness, fetchImpl = fetch) {
|
|
253
|
+
if (!config?.token || !config.backendUrl)
|
|
254
|
+
return Promise.resolve();
|
|
255
|
+
return fetchImpl(`${config.backendUrl}/api/cli/promptBlocked`, {
|
|
256
|
+
method: "POST",
|
|
257
|
+
headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json" },
|
|
258
|
+
body: JSON.stringify({ rule: rule.slice(0, 80), harness }),
|
|
259
|
+
signal: AbortSignal.timeout(3_000),
|
|
260
|
+
}).then(() => undefined, () => undefined);
|
|
261
|
+
}
|