@inetafrica/open-claudia 3.5.18 → 3.5.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/bin/cli.js +6 -0
- package/bin/teach.js +78 -0
- package/bot.js +7 -0
- package/channels/kazee/adapter.js +30 -0
- package/core/bot-apps.js +133 -0
- package/core/browser-control.js +540 -0
- package/core/browser-lock.js +51 -0
- package/core/browser-teach.js +1012 -0
- package/core/browser-view.js +5 -2
- package/core/cards.js +4 -2
- package/core/dedup-adjudicator.js +42 -19
- package/core/recall-filter.js +28 -9
- package/core/system-prompt.js +11 -1
- package/core/tool-guard.js +343 -0
- package/core/utility-agent.js +7 -0
- package/package.json +2 -2
- package/test-provider-language.js +10 -1
- package/test-provider-recall.js +38 -5
- package/test-utility-provider-policy.js +27 -0
- package/web.js +68 -0
package/core/browser-view.js
CHANGED
|
@@ -98,13 +98,15 @@ function listSessions({ dirs = socketDirs(), alive = processAlive } = {}) {
|
|
|
98
98
|
} catch (_) { /* try the next file */ }
|
|
99
99
|
}
|
|
100
100
|
seen.add(name);
|
|
101
|
-
|
|
101
|
+
// dir: the socket folder, so the bot's own agent-browser calls (core/browser-teach.js) reach
|
|
102
|
+
// this session and never start a new one. Internal like the port.
|
|
103
|
+
sessions.push({ name, port, page, updatedAt, dir });
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
return sessions.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
// Public view of a session: the stream port
|
|
109
|
+
// Public view of a session: the stream port and socket folder stay inside the bot.
|
|
108
110
|
function describeSessions(options) {
|
|
109
111
|
return listSessions(options).map(({ name, page, updatedAt }) => ({ name, page, updatedAt }));
|
|
110
112
|
}
|
|
@@ -189,6 +191,7 @@ function serveStream(res, sessionName, { fps, sessions, connect, keepaliveMs = K
|
|
|
189
191
|
module.exports = {
|
|
190
192
|
DEFAULT_FPS,
|
|
191
193
|
MAX_FPS,
|
|
194
|
+
SESSION_NAME,
|
|
192
195
|
clampFps,
|
|
193
196
|
describeSessions,
|
|
194
197
|
listSessions,
|
package/core/cards.js
CHANGED
|
@@ -255,7 +255,9 @@ function describeResponse(rec, payload, now = Date.now()) {
|
|
|
255
255
|
// ---------------------------------------------------------------------------
|
|
256
256
|
// Waking the conversation a card (or decision) came from
|
|
257
257
|
|
|
258
|
-
|
|
258
|
+
// `source` tags the job for job lists and audits: "card" for card answers,
|
|
259
|
+
// "browser" for the Browser app's hand-backs and Teach-me recordings.
|
|
260
|
+
function wakeOrigin(origin, prompt, label, { scheduler, delayMs = 1500, source = "card" } = {}) {
|
|
259
261
|
if (!origin || !origin.channelId || !origin.provider || !origin.project || !origin.canonicalUserId) {
|
|
260
262
|
throw new Error("the card's origin conversation is unknown");
|
|
261
263
|
}
|
|
@@ -282,7 +284,7 @@ function wakeOrigin(origin, prompt, label, { scheduler, delayMs = 1500 } = {}) {
|
|
|
282
284
|
},
|
|
283
285
|
prompt,
|
|
284
286
|
label: String(label).slice(0, 60),
|
|
285
|
-
source
|
|
287
|
+
source,
|
|
286
288
|
fireAt: Date.now() + delayMs,
|
|
287
289
|
});
|
|
288
290
|
}
|
|
@@ -17,23 +17,32 @@ const GATE_MIN_SCORE = Number(process.env.PACK_DEDUP_GATE_SCORE || 25);
|
|
|
17
17
|
const ADJUDICATOR_TIMEOUT_MS = Number(process.env.PACK_DEDUP_TIMEOUT_MS || 45000);
|
|
18
18
|
const MAX_CANDIDATES_PER_FACT = 3;
|
|
19
19
|
|
|
20
|
+
// The root must be an object: Claude Code turns the schema into a tool whose
|
|
21
|
+
// input_schema the API requires to be an object. The bare array this used to
|
|
22
|
+
// be got a 400 on every call from 3.2.2 to 3.5.18, so dedup failed open.
|
|
20
23
|
const ADJUDICATOR_OUTPUT_SCHEMA = Object.freeze({
|
|
21
|
-
type: "
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
type: "object",
|
|
25
|
+
required: ["verdicts"],
|
|
26
|
+
properties: {
|
|
27
|
+
verdicts: {
|
|
28
|
+
type: "array",
|
|
29
|
+
items: {
|
|
30
|
+
type: "object",
|
|
31
|
+
required: ["id", "verdict"],
|
|
32
|
+
properties: {
|
|
33
|
+
id: { type: "string" },
|
|
34
|
+
verdict: { type: "string", enum: ["store", "update", "merge", "skip"] },
|
|
35
|
+
target: { type: "string" },
|
|
36
|
+
merged: { type: "string" },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
30
39
|
},
|
|
31
40
|
},
|
|
32
41
|
});
|
|
33
42
|
|
|
34
43
|
const ADJUDICATOR_SYSTEM_PROMPT = [
|
|
35
44
|
"You are a dedup judge for an assistant's long-term memory.",
|
|
36
|
-
|
|
45
|
+
'Reply with ONLY a JSON object {"verdicts": [...]} — no prose, no markdown fences.',
|
|
37
46
|
].join("\n");
|
|
38
47
|
|
|
39
48
|
function enabled() {
|
|
@@ -113,20 +122,29 @@ function buildAdjudicationPrompt(pairs) {
|
|
|
113
122
|
"",
|
|
114
123
|
...blocks,
|
|
115
124
|
"",
|
|
116
|
-
`Reply with ONLY a JSON
|
|
125
|
+
`Reply with ONLY a JSON object like {"verdicts":[{"id":"f1","verdict":"store"}]} covering every FACT id.`,
|
|
117
126
|
].join("\n");
|
|
118
127
|
}
|
|
119
128
|
|
|
129
|
+
// The reply is {"verdicts": [...]}; a bare array is still read.
|
|
130
|
+
function verdictList(value) {
|
|
131
|
+
if (Array.isArray(value)) return value;
|
|
132
|
+
return value && typeof value === "object" && Array.isArray(value.verdicts) ? value.verdicts : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
120
135
|
function parseVerdicts(result) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
136
|
+
const fromJson = verdictList(result?.json);
|
|
137
|
+
if (fromJson) return fromJson;
|
|
138
|
+
const text = String(result?.text || "");
|
|
139
|
+
for (const pattern of [/\{[\s\S]*\}/, /\[[\s\S]*\]/]) {
|
|
140
|
+
const match = text.match(pattern);
|
|
141
|
+
if (!match) continue;
|
|
142
|
+
try {
|
|
143
|
+
const list = verdictList(JSON.parse(match[0]));
|
|
144
|
+
if (list) return list;
|
|
145
|
+
} catch (_) { /* try the other shape */ }
|
|
129
146
|
}
|
|
147
|
+
return null;
|
|
130
148
|
}
|
|
131
149
|
|
|
132
150
|
// Phase 3 — turn raw verdicts into a transformed decision plus replacement
|
|
@@ -220,6 +238,11 @@ function createDedupAdjudicator(dependencies = {}) {
|
|
|
220
238
|
});
|
|
221
239
|
rawVerdicts = parseVerdicts(result) || [];
|
|
222
240
|
llmUsed = true;
|
|
241
|
+
// One line per judgement, so its verdicts can be checked in the log
|
|
242
|
+
// (before 3.5.19 it logged only failures, and failed on every call).
|
|
243
|
+
const counts = {};
|
|
244
|
+
for (const v of rawVerdicts) if (v && typeof v.verdict === "string") counts[v.verdict] = (counts[v.verdict] || 0) + 1;
|
|
245
|
+
console.log(`[dedup-adjudicator] judged ${pairs.length} fact${pairs.length === 1 ? "" : "s"}: ${Object.entries(counts).map(([verdict, n]) => `${n} ${verdict}`).join(", ") || "no verdicts"}`);
|
|
223
246
|
} catch (error) {
|
|
224
247
|
console.warn(`[dedup-adjudicator] failed open: ${error.message}`);
|
|
225
248
|
rawVerdicts = [];
|
package/core/recall-filter.js
CHANGED
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
const FILTER_TIMEOUT_MS = Number(process.env.RECALL_FILTER_TIMEOUT_MS || 20000);
|
|
8
8
|
const MESSAGE_CLIP_CHARS = 1500;
|
|
9
9
|
const CONTEXT_CLIP_CHARS = 3500;
|
|
10
|
+
// The root must be an object: Claude Code turns the schema into a tool whose
|
|
11
|
+
// input_schema the API requires to be an object, so the bare array this used
|
|
12
|
+
// to be got a 400 on every Claude call and the filter failed open.
|
|
10
13
|
const FILTER_OUTPUT_SCHEMA = Object.freeze({
|
|
11
|
-
type: "
|
|
12
|
-
|
|
14
|
+
type: "object",
|
|
15
|
+
required: ["relevant"],
|
|
16
|
+
properties: {
|
|
17
|
+
relevant: { type: "array", items: { type: "string" } },
|
|
18
|
+
},
|
|
13
19
|
});
|
|
14
20
|
|
|
15
21
|
function enabled() {
|
|
@@ -18,15 +24,28 @@ function enabled() {
|
|
|
18
24
|
|
|
19
25
|
const FILTER_SYSTEM_PROMPT = [
|
|
20
26
|
"You are a relevance judge for an assistant's memory recall.",
|
|
21
|
-
|
|
27
|
+
'Reply with ONLY a JSON object {"relevant": [candidate ids]} — no prose, no markdown fences.',
|
|
22
28
|
].join("\n");
|
|
23
29
|
|
|
30
|
+
// The reply is {"relevant": [...]}; a bare array is still read.
|
|
31
|
+
function idList(value) {
|
|
32
|
+
if (Array.isArray(value)) return value;
|
|
33
|
+
return value && typeof value === "object" && Array.isArray(value.relevant) ? value.relevant : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
function parseIds(result) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
37
|
+
const fromJson = idList(result?.json);
|
|
38
|
+
if (fromJson) return fromJson;
|
|
39
|
+
const text = String(result?.text || "");
|
|
40
|
+
for (const pattern of [/\{[\s\S]*\}/, /\[[\s\S]*?\]/]) {
|
|
41
|
+
const match = text.match(pattern);
|
|
42
|
+
if (!match) continue;
|
|
43
|
+
try {
|
|
44
|
+
const ids = idList(JSON.parse(match[0]));
|
|
45
|
+
if (ids) return ids;
|
|
46
|
+
} catch (_) { /* try the other shape */ }
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
30
49
|
}
|
|
31
50
|
|
|
32
51
|
function createRecallFilter(dependencies = {}) {
|
|
@@ -62,7 +81,7 @@ function createRecallFilter(dependencies = {}) {
|
|
|
62
81
|
"Candidates:",
|
|
63
82
|
...lines,
|
|
64
83
|
"",
|
|
65
|
-
`Reply with ONLY a JSON
|
|
84
|
+
`Reply with ONLY a JSON object of the relevant ids, e.g. {"relevant": ["${candidates[0].id}"]}. Use {"relevant": []} if none are relevant.`,
|
|
66
85
|
].join("\n");
|
|
67
86
|
try {
|
|
68
87
|
const result = await runUtility(prompt, {
|
package/core/system-prompt.js
CHANGED
|
@@ -239,6 +239,16 @@ Raw shell one-liners and heredocs are for probing and diagnosis only — never t
|
|
|
239
239
|
${listing}`;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Kazee Chat's Browser app (core/bot-apps.js): only on a bot that offers it, so a bot without
|
|
243
|
+
// Agent Space is never told to send an Open Browser button that cannot open.
|
|
244
|
+
function buildBrowserAppBlock() {
|
|
245
|
+
let apps = null;
|
|
246
|
+
try { apps = require("./bot-apps").appsToRegister(process.env); } catch (_) { apps = null; }
|
|
247
|
+
if (!apps || !apps.some((app) => app.key === "browser")) return "";
|
|
248
|
+
return `
|
|
249
|
+
Kazee Chat Browser app: people in a direct chat with you can watch this browser live, take it over, and teach you a task (Teach me). When you need a person in the browser (a login, a captcha, a one-time code, a judgement call), send a card in their direct chat with an Open Browser button, say what you need, then wait: you are woken in that chat when they hand it back. For example \`open-claudia card send '{"v":1,"kind":"report","title":"I need you in the browser","body":"<what you need and why>","open":{"app":"browser","label":"Open Browser"}}'\`. While someone drives, your agent-browser commands other than get, snapshot and screenshot are refused. Never ask for passwords in chat.`;
|
|
250
|
+
}
|
|
251
|
+
|
|
242
252
|
function buildCoreInstructionsUncached() {
|
|
243
253
|
const soul = loadSoul();
|
|
244
254
|
// Gate on actual TTS capability, not local Whisper: hosted pods hear and
|
|
@@ -368,7 +378,7 @@ Core workflow — open, snapshot to get element refs, then act on those refs:
|
|
|
368
378
|
- \`agent-browser eval "<js>"\` — run JS in the page when no command fits.
|
|
369
379
|
- \`agent-browser close\` — end the session when done.
|
|
370
380
|
|
|
371
|
-
Notes: a background daemon persists between commands, so navigation state carries across calls in a turn. Prefer \`snapshot -i\` refs over guessing selectors. Keep output lean by acting on refs rather than dumping full HTML. This replaced the old Playwright MCP — do not look for a playwright tool
|
|
381
|
+
Notes: a background daemon persists between commands, so navigation state carries across calls in a turn. Prefer \`snapshot -i\` refs over guessing selectors. Keep output lean by acting on refs rather than dumping full HTML. This replaced the old Playwright MCP — do not look for a playwright tool.${buildBrowserAppBlock()}
|
|
372
382
|
|
|
373
383
|
If you tell the user "I'll check back in N minutes" or "I'll run this every morning", you MUST schedule it with one of the commands above in the same turn. Otherwise nothing happens.
|
|
374
384
|
|
package/core/tool-guard.js
CHANGED
|
@@ -659,6 +659,340 @@ function hookFailure(toolName, reason) {
|
|
|
659
659
|
};
|
|
660
660
|
}
|
|
661
661
|
|
|
662
|
+
// ── Browser app: the bot pauses while a person drives (bot apps v1, C7) ────
|
|
663
|
+
// While someone drives the bot's browser from Kazee Chat, the driver lock file
|
|
664
|
+
// (core/browser-lock.js) is live, and the bot's own agent-browser commands
|
|
665
|
+
// would fight their clicks. So an agent-browser call at a command position is
|
|
666
|
+
// refused, except the read-only ones: get, snapshot, screenshot, session list.
|
|
667
|
+
// A command position is the start of a command: the start of the line, or
|
|
668
|
+
// after ; & | && || ( $( a backtick or a newline, behind env assignments
|
|
669
|
+
// (FOO=1), shell words (if, then, do, !, {) and wrappers (npx, sudo, env,
|
|
670
|
+
// timeout, nice, xargs...). What a shell runs from a string is read the same
|
|
671
|
+
// way: bash/sh -c '...', eval, a heredoc or here-string fed to a shell, and
|
|
672
|
+
// $(...) or backticks, inside double quotes too. Any other string is data, so
|
|
673
|
+
// `echo "agent-browser click"`, `grep agent-browser`, a commit message or a
|
|
674
|
+
// heredoc written to a file never match. An expired lock never blocks, and any
|
|
675
|
+
// error here allows: this is a pause for a person's sake, not a security gate.
|
|
676
|
+
const AGENT_BROWSER_READ_ONLY = new Set(["get", "snapshot", "screenshot"]);
|
|
677
|
+
const AGENT_BROWSER_VALUE_FLAGS = new Set([
|
|
678
|
+
"--session", "--restore-save", "--restore-check-url", "--restore-check-text", "--restore-check-fn",
|
|
679
|
+
"--namespace", "--session-name", "--profile", "--state", "--headers", "--executable-path", "--extension",
|
|
680
|
+
"--init-script", "--enable", "--args", "--user-agent", "--proxy", "--proxy-bypass", "--ca-cert",
|
|
681
|
+
"--hide-scrollbars", "-p", "--provider", "--device", "--threshold", "--screenshot-dir", "--screenshot-quality",
|
|
682
|
+
"--screenshot-format", "--cdp", "--color-scheme", "--download-path", "--max-output", "--allowed-domains",
|
|
683
|
+
"--action-policy", "--confirm-actions", "--engine", "--input-mode", "--idle-timeout", "--model", "--config",
|
|
684
|
+
"--port", "--allowed-origins",
|
|
685
|
+
]);
|
|
686
|
+
// agent-browser, agent-browser.js, agent-browser@latest, agent-browser-linux-x64...
|
|
687
|
+
const AGENT_BROWSER_BIN = /^agent-browser(?:$|[.@-])/;
|
|
688
|
+
const SHELL_WORDS = new Set(["!", "{", "if", "then", "else", "elif", "do", "while", "until"]);
|
|
689
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "mksh", "fish"]);
|
|
690
|
+
// Wrappers that run the command after them, with their options that take a value.
|
|
691
|
+
const COMMAND_WRAPPERS = new Map([
|
|
692
|
+
["sudo", ["-u", "-g", "-C", "-h", "-p", "-r", "-t", "-U", "-D", "-R", "-T", "--user", "--group", "--host", "--prompt", "--role", "--type", "--other-user", "--chdir", "--chroot", "--command-timeout", "--close-from"]],
|
|
693
|
+
["doas", ["-u", "-C"]],
|
|
694
|
+
["env", ["-u", "-C", "-S", "--unset", "--chdir", "--split-string"]],
|
|
695
|
+
["command", []], ["builtin", []], ["exec", ["-a"]], ["nohup", []], ["setsid", []],
|
|
696
|
+
["time", ["-f", "-o", "--format", "--output"]],
|
|
697
|
+
["nice", ["-n", "--adjustment"]],
|
|
698
|
+
["timeout", ["-s", "-k", "--signal", "--kill-after"]],
|
|
699
|
+
["stdbuf", ["-i", "-o", "-e", "--input", "--output", "--error"]],
|
|
700
|
+
["xargs", ["-I", "-n", "-L", "-P", "-d", "-E", "-s", "-a", "--max-args", "--max-lines", "--max-procs", "--delimiter", "--eof", "--max-chars", "--arg-file"]],
|
|
701
|
+
["npx", ["-p", "--package", "-c", "--call"]],
|
|
702
|
+
["pnpx", ["-p", "--package"]],
|
|
703
|
+
["bunx", ["-p", "--package"]],
|
|
704
|
+
]);
|
|
705
|
+
// Wrapper options whose value is itself a command line: npx -c, env -S.
|
|
706
|
+
const WRAPPER_SCRIPT_OPTIONS = new Set(["-c", "--call", "-S", "--split-string"]);
|
|
707
|
+
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
708
|
+
|
|
709
|
+
const baseName = (word) => String(word).split("/").pop();
|
|
710
|
+
|
|
711
|
+
// Where $( ... ) that opens at `start` (just after "$(") closes: the index of
|
|
712
|
+
// its ")", or the end of the text. Nested parentheses and quotes are skipped.
|
|
713
|
+
function substitutionEnd(s, start) {
|
|
714
|
+
let depth = 1;
|
|
715
|
+
let i = start;
|
|
716
|
+
while (i < s.length) {
|
|
717
|
+
const c = s[i];
|
|
718
|
+
if (c === "\\") { i += 2; continue; }
|
|
719
|
+
if (c === "'") { const end = s.indexOf("'", i + 1); i = end < 0 ? s.length : end + 1; continue; }
|
|
720
|
+
if (c === '"') {
|
|
721
|
+
let j = i + 1;
|
|
722
|
+
while (j < s.length && s[j] !== '"') j += s[j] === "\\" ? 2 : 1;
|
|
723
|
+
i = j + 1;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (c === "(") depth += 1;
|
|
727
|
+
else if (c === ")") { depth -= 1; if (depth === 0) return i; }
|
|
728
|
+
i += 1;
|
|
729
|
+
}
|
|
730
|
+
return s.length;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// The commands of a shell command line, the way a shell splits them: each is
|
|
734
|
+
// { words, input } with quotes removed, redirection targets and comments left
|
|
735
|
+
// out, and `input` the heredoc bodies and here-strings it reads. `nested` holds
|
|
736
|
+
// the text of every $(...) and backtick, which the shell also runs.
|
|
737
|
+
function shellCommands(command) {
|
|
738
|
+
const s = String(command || "");
|
|
739
|
+
const commands = [];
|
|
740
|
+
const nested = [];
|
|
741
|
+
let words = [];
|
|
742
|
+
let input = [];
|
|
743
|
+
let word = null;
|
|
744
|
+
let target = false; // the next word is a redirection's file or descriptor
|
|
745
|
+
let hereString = false; // the next word is a here-string (<<<)
|
|
746
|
+
let heredocs = []; // heredocs whose bodies start after the next newline
|
|
747
|
+
const endWord = () => {
|
|
748
|
+
if (word === null) return;
|
|
749
|
+
if (target) target = false;
|
|
750
|
+
else if (hereString) { hereString = false; input.push(word); }
|
|
751
|
+
else words.push(word);
|
|
752
|
+
word = null;
|
|
753
|
+
};
|
|
754
|
+
const endCommand = () => {
|
|
755
|
+
endWord();
|
|
756
|
+
if (words.length) commands.push({ words, input });
|
|
757
|
+
words = [];
|
|
758
|
+
input = [];
|
|
759
|
+
target = false;
|
|
760
|
+
hereString = false;
|
|
761
|
+
};
|
|
762
|
+
const backtick = (i) => {
|
|
763
|
+
let j = i + 1;
|
|
764
|
+
while (j < s.length && s[j] !== "`") j += s[j] === "\\" ? 2 : 1;
|
|
765
|
+
nested.push(s.slice(i + 1, Math.min(j, s.length)));
|
|
766
|
+
return j + 1;
|
|
767
|
+
};
|
|
768
|
+
let i = 0;
|
|
769
|
+
while (i < s.length) {
|
|
770
|
+
const c = s[i];
|
|
771
|
+
if (c === "\\") {
|
|
772
|
+
if (s[i + 1] === "\n") { i += 2; continue; } // a line continuation
|
|
773
|
+
if (i + 1 < s.length) word = (word || "") + s[i + 1];
|
|
774
|
+
i += 2;
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (c === "#" && word === null) { // a comment, to the end of the line
|
|
778
|
+
while (i < s.length && s[i] !== "\n") i += 1;
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (c === "'") {
|
|
782
|
+
const end = s.indexOf("'", i + 1) < 0 ? s.length : s.indexOf("'", i + 1);
|
|
783
|
+
word = (word || "") + s.slice(i + 1, end);
|
|
784
|
+
i = end + 1;
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (c === '"') {
|
|
788
|
+
let j = i + 1;
|
|
789
|
+
let text = "";
|
|
790
|
+
while (j < s.length && s[j] !== '"') {
|
|
791
|
+
if (s[j] === "\\" && j + 1 < s.length) {
|
|
792
|
+
if (s[j + 1] !== "\n") text += /[$`"\\]/.test(s[j + 1]) ? s[j + 1] : s[j] + s[j + 1];
|
|
793
|
+
j += 2;
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (s[j] === "$" && s[j + 1] === "(") {
|
|
797
|
+
const end = substitutionEnd(s, j + 2);
|
|
798
|
+
nested.push(s.slice(j + 2, end));
|
|
799
|
+
text += s.slice(j, end + 1);
|
|
800
|
+
j = end + 1;
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (s[j] === "`") {
|
|
804
|
+
const after = backtick(j);
|
|
805
|
+
text += s.slice(j, after);
|
|
806
|
+
j = after;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
text += s[j];
|
|
810
|
+
j += 1;
|
|
811
|
+
}
|
|
812
|
+
word = (word || "") + text;
|
|
813
|
+
i = j + 1;
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
if (c === "`") {
|
|
817
|
+
const after = backtick(i);
|
|
818
|
+
word = (word || "") + s.slice(i, after);
|
|
819
|
+
i = after;
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (c === "$" && s[i + 1] === "(") {
|
|
823
|
+
const end = substitutionEnd(s, i + 2);
|
|
824
|
+
nested.push(s.slice(i + 2, end));
|
|
825
|
+
word = (word || "") + s.slice(i, end + 1);
|
|
826
|
+
i = end + 1;
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if (c === "<" && s.startsWith("<<<", i)) { endWord(); hereString = true; i += 3; continue; }
|
|
830
|
+
if (c === "<" && s[i + 1] === "<") { // a heredoc: its body follows the next newline
|
|
831
|
+
endWord();
|
|
832
|
+
i += 2;
|
|
833
|
+
const strip = s[i] === "-";
|
|
834
|
+
if (strip) i += 1;
|
|
835
|
+
while (s[i] === " " || s[i] === "\t") i += 1;
|
|
836
|
+
let delim = "";
|
|
837
|
+
while (i < s.length && !/[\s;&|<>()]/.test(s[i])) {
|
|
838
|
+
if (s[i] === "'" || s[i] === '"') {
|
|
839
|
+
const q = s[i];
|
|
840
|
+
const end = s.indexOf(q, i + 1) < 0 ? s.length : s.indexOf(q, i + 1);
|
|
841
|
+
delim += s.slice(i + 1, end);
|
|
842
|
+
i = end + 1;
|
|
843
|
+
} else {
|
|
844
|
+
if (s[i] !== "\\") delim += s[i];
|
|
845
|
+
i += 1;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (delim) heredocs.push({ delim, strip, input });
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
if (c === "<" || c === ">") { // a redirection: the word after it is a file, not an argument
|
|
852
|
+
if (word !== null && /^\d+$/.test(word)) word = null; // the descriptor of 2>, 1>&...
|
|
853
|
+
endWord();
|
|
854
|
+
i += /[>&|]/.test(s[i + 1] || "") ? 2 : 1;
|
|
855
|
+
target = true;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (c === "\n" || c === "\r") {
|
|
859
|
+
endCommand();
|
|
860
|
+
i += 1;
|
|
861
|
+
if (c === "\n" && heredocs.length) {
|
|
862
|
+
for (const doc of heredocs) {
|
|
863
|
+
const body = [];
|
|
864
|
+
while (i < s.length) {
|
|
865
|
+
const eol = s.indexOf("\n", i) < 0 ? s.length : s.indexOf("\n", i);
|
|
866
|
+
let line = s.slice(i, eol);
|
|
867
|
+
if (doc.strip) line = line.replace(/^\t+/, "");
|
|
868
|
+
i = eol + 1;
|
|
869
|
+
if (line === doc.delim) break;
|
|
870
|
+
body.push(line);
|
|
871
|
+
}
|
|
872
|
+
doc.input.push(body.join("\n"));
|
|
873
|
+
}
|
|
874
|
+
heredocs = [];
|
|
875
|
+
}
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
if (/[;&|()]/.test(c)) { endCommand(); i += 1; continue; }
|
|
879
|
+
if (/\s/.test(c)) { endWord(); i += 1; continue; }
|
|
880
|
+
word = (word || "") + c;
|
|
881
|
+
i += 1;
|
|
882
|
+
}
|
|
883
|
+
endCommand();
|
|
884
|
+
return { commands, nested };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// The index of the command's own name, behind env assignments, shell words and
|
|
888
|
+
// wrappers with their options (-1 when there is none). A wrapper option whose
|
|
889
|
+
// value is a command line (npx -c, env -S) is added to `scripts`.
|
|
890
|
+
function commandWordIndex(words, scripts) {
|
|
891
|
+
let k = 0;
|
|
892
|
+
for (let guard = 0; guard < 16 && k < words.length; guard += 1) {
|
|
893
|
+
const w = words[k];
|
|
894
|
+
if (ENV_ASSIGNMENT.test(w) || SHELL_WORDS.has(w)) { k += 1; continue; }
|
|
895
|
+
const name = baseName(w);
|
|
896
|
+
const valueOptions = COMMAND_WRAPPERS.get(name);
|
|
897
|
+
if (!valueOptions) return k;
|
|
898
|
+
k += 1;
|
|
899
|
+
let positional = name === "timeout" ? 1 : 0; // timeout DURATION command
|
|
900
|
+
while (k < words.length) {
|
|
901
|
+
const arg = words[k];
|
|
902
|
+
if (arg === "--") { k += 1; break; }
|
|
903
|
+
if (name === "env" && ENV_ASSIGNMENT.test(arg)) { k += 1; continue; }
|
|
904
|
+
if (arg.length > 1 && arg.startsWith("-")) {
|
|
905
|
+
if (valueOptions.includes(arg)) {
|
|
906
|
+
if (WRAPPER_SCRIPT_OPTIONS.has(arg) && words[k + 1] !== undefined) scripts.push(words[k + 1]);
|
|
907
|
+
k += 2;
|
|
908
|
+
} else {
|
|
909
|
+
k += 1;
|
|
910
|
+
}
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
if (positional > 0) { positional -= 1; k += 1; continue; }
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return k < words.length ? k : -1;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// agent-browser's subcommand and the word after it (sub is null when there is
|
|
921
|
+
// none, e.g. --help or --version).
|
|
922
|
+
function agentBrowserCall(words, at) {
|
|
923
|
+
let j = at + 1;
|
|
924
|
+
while (j < words.length) {
|
|
925
|
+
const arg = words[j];
|
|
926
|
+
if (arg === "--") { j += 1; continue; }
|
|
927
|
+
if (arg.startsWith("-")) {
|
|
928
|
+
if (!arg.includes("=") && AGENT_BROWSER_VALUE_FLAGS.has(arg)) j += 2;
|
|
929
|
+
else j += /^(true|false)$/.test(words[j + 1] || "") ? 2 : 1; // boolean flags take an optional true/false
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
return { sub: arg, sub2: words[j + 1] === undefined ? null : words[j + 1] };
|
|
933
|
+
}
|
|
934
|
+
return { sub: null, sub2: null };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Every agent-browser invocation in a command: [{ sub, sub2 }].
|
|
938
|
+
function agentBrowserInvocations(command, depth = 0) {
|
|
939
|
+
const calls = [];
|
|
940
|
+
if (depth > 4) return calls;
|
|
941
|
+
const { commands, nested } = shellCommands(command);
|
|
942
|
+
const scripts = [...nested];
|
|
943
|
+
for (const { words, input } of commands) {
|
|
944
|
+
const at = commandWordIndex(words, scripts);
|
|
945
|
+
if (at < 0) continue;
|
|
946
|
+
const name = baseName(words[at]);
|
|
947
|
+
if (AGENT_BROWSER_BIN.test(name)) {
|
|
948
|
+
calls.push(agentBrowserCall(words, at));
|
|
949
|
+
} else if (name === "eval") {
|
|
950
|
+
scripts.push(words.slice(at + 1).join(" "));
|
|
951
|
+
} else if (SHELLS.has(name)) {
|
|
952
|
+
// sh -c '<script>' [name args], else the script a shell reads from its heredoc/here-string.
|
|
953
|
+
let script = null;
|
|
954
|
+
for (let j = at + 1; j < words.length; j += 1) {
|
|
955
|
+
const w = words[j];
|
|
956
|
+
if (/^-[A-Za-z]*c[A-Za-z]*$/.test(w)) {
|
|
957
|
+
script = words.slice(j + 1).find((x) => !x.startsWith("-"));
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
if (w === "-o" || w === "+o" || w === "-O" || w === "+O") { j += 1; continue; }
|
|
961
|
+
if (!w.startsWith("-") && !w.startsWith("+")) break; // a script file
|
|
962
|
+
}
|
|
963
|
+
if (script != null) scripts.push(script);
|
|
964
|
+
else scripts.push(...input);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
for (const script of scripts) calls.push(...agentBrowserInvocations(script, depth + 1));
|
|
968
|
+
return calls;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function readOnlyBrowserCall(call) {
|
|
972
|
+
if (!call.sub) return true; // no subcommand: usage or version
|
|
973
|
+
if (AGENT_BROWSER_READ_ONLY.has(call.sub)) return true;
|
|
974
|
+
return call.sub === "session" && call.sub2 === "list";
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// null, or { subcommand, message } when a live driver lock refuses this command.
|
|
978
|
+
function browserDriverDenial(command, now = Date.now()) {
|
|
979
|
+
try {
|
|
980
|
+
const cmd = String(command || "");
|
|
981
|
+
if (!cmd.includes("agent-browser")) return null;
|
|
982
|
+
const lock = require("./browser-lock").liveLock(now);
|
|
983
|
+
if (!lock) return null;
|
|
984
|
+
const blocked = agentBrowserInvocations(cmd).find((call) => !readOnlyBrowserCall(call));
|
|
985
|
+
if (!blocked) return null;
|
|
986
|
+
const name = String(lock.name || "").replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 60) || "Someone";
|
|
987
|
+
return {
|
|
988
|
+
subcommand: String(blocked.sub || "").slice(0, 40),
|
|
989
|
+
message: `${name} is driving the browser right now. Wait for them to hand it back; you will be woken in their chat when they do.`,
|
|
990
|
+
};
|
|
991
|
+
} catch (e) {
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
662
996
|
// Claude sends the shell command as a string; accept an argv array too.
|
|
663
997
|
function shellCommandOf(input) {
|
|
664
998
|
if (!input || typeof input !== "object") return null;
|
|
@@ -727,6 +1061,14 @@ function runHook(payload) {
|
|
|
727
1061
|
logGuardEvent({ kind: "deny", rule: "external-secret-read", pattern: secretDenial.rule, command: String(command).slice(0, 300) });
|
|
728
1062
|
return { exit: 2, stderr: secretDenial.message };
|
|
729
1063
|
}
|
|
1064
|
+
// A person is driving the browser (Browser app): ahead of the tooling-mode
|
|
1065
|
+
// switch, so relaxed mode cannot drive over them either. Only the
|
|
1066
|
+
// subcommand is logged: the bot's own fill/type commands can carry text.
|
|
1067
|
+
const driving = browserDriverDenial(command);
|
|
1068
|
+
if (driving) {
|
|
1069
|
+
logGuardEvent({ kind: "deny", rule: "browser-driver", subcommand: driving.subcommand });
|
|
1070
|
+
return { exit: 2, stderr: driving.message };
|
|
1071
|
+
}
|
|
730
1072
|
// Relaxed tooling mode: the gate observes but never blocks. The event is
|
|
731
1073
|
// still logged (as relaxed-pass, not deny) so the dream KPI can report
|
|
732
1074
|
// what WOULD have been denied — visibility survives the mode switch.
|
|
@@ -761,4 +1103,5 @@ module.exports = {
|
|
|
761
1103
|
externalFileMutationDenial, externalControlPlaneDenial, guardedSpeakerFromEnv,
|
|
762
1104
|
protectedReadRoots, isProtectedReadPath, isProtectedSearchRoot, secretReadRule,
|
|
763
1105
|
matchRawOp, noteRawOp, readGuardEvents,
|
|
1106
|
+
agentBrowserInvocations, browserDriverDenial,
|
|
764
1107
|
};
|
package/core/utility-agent.js
CHANGED
|
@@ -123,6 +123,13 @@ function validateSchema(value, schema, location = "$", depth = 0) {
|
|
|
123
123
|
|
|
124
124
|
function prepareSchemaFile(schema) {
|
|
125
125
|
if (!schema) return null;
|
|
126
|
+
// Claude Code turns the schema into a tool whose input_schema the API
|
|
127
|
+
// requires to be an object, and OpenAI strict mode wants an object root too.
|
|
128
|
+
// Any other root is refused on every call, which a fail-open caller shows
|
|
129
|
+
// only as a warning (the dedup adjudicator's array root, 3.2.2 to 3.5.18).
|
|
130
|
+
if (typeof schema !== "object" || Array.isArray(schema) || schema.type !== "object") {
|
|
131
|
+
throw new UtilityAgentError("INVALID_UTILITY_SCHEMA", 'outputSchema must have top-level type "object"');
|
|
132
|
+
}
|
|
126
133
|
let serialized;
|
|
127
134
|
try { serialized = JSON.stringify(schema); }
|
|
128
135
|
catch (_) { throw new UtilityAgentError("INVALID_UTILITY_SCHEMA", "outputSchema must be JSON-serializable"); }
|