context-mode 1.0.163 → 1.0.165
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +34 -29
- package/build/adapters/antigravity-cli/index.d.ts +1 -1
- package/build/adapters/antigravity-cli/index.js +7 -6
- package/build/adapters/pi/extension.js +14 -13
- package/build/lifecycle.d.ts +48 -0
- package/build/lifecycle.js +111 -0
- package/build/security.d.ts +65 -0
- package/build/security.js +138 -4
- package/build/server.js +73 -4
- package/build/session/extract.js +70 -0
- package/cli.bundle.mjs +184 -183
- package/configs/antigravity-cli/plugin.json +1 -1
- package/configs/copilot-cli/.github/plugin/plugin.json +1 -1
- package/hooks/routing-block.mjs +1 -2
- package/hooks/security.bundle.mjs +2 -2
- package/hooks/session-extract.bundle.mjs +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -3
- package/server.bundle.mjs +141 -140
- package/start.mjs +87 -15
- package/scripts/install-antigravity-cli-plugin.mjs +0 -141
package/build/security.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, realpathSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { relative, resolve, sep } from "node:path";
|
|
3
3
|
import { resolveAdapterGlobalSettingsPaths } from "./util/claude-config.js";
|
|
4
4
|
// ==============================================================================
|
|
5
5
|
// Pattern Parsing
|
|
@@ -367,6 +367,21 @@ export function readBashPolicies(projectDir, globalSettingsPath) {
|
|
|
367
367
|
* Each inner array contains the extracted glob strings.
|
|
368
368
|
*/
|
|
369
369
|
export function readToolDenyPatterns(toolName, projectDir, globalSettingsPath) {
|
|
370
|
+
return readToolPermissionPatterns(toolName, "deny", projectDir, globalSettingsPath);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Read `permissions.{deny|allow}` globs for a tool from every settings file in
|
|
374
|
+
* precedence order (project local → project shared → adapter globals).
|
|
375
|
+
*
|
|
376
|
+
* Generalizes the original deny-only reader so the project-boundary guard
|
|
377
|
+
* (#852) can consult the SAME `permissions.allow` rules the user already
|
|
378
|
+
* maintains for the host's `Read` tool — instead of inventing a context-mode-
|
|
379
|
+
* specific opt-out env that would rot into dead code. A user who legitimately
|
|
380
|
+
* needs an out-of-project read expresses it once, in the host config, e.g.
|
|
381
|
+
* `"permissions": { "allow": ["Read(/var/log/**)"] }`, and both the host and
|
|
382
|
+
* context-mode honor it.
|
|
383
|
+
*/
|
|
384
|
+
export function readToolPermissionPatterns(toolName, kind, projectDir, globalSettingsPath) {
|
|
370
385
|
const result = [];
|
|
371
386
|
const extractGlobs = (path) => {
|
|
372
387
|
let raw;
|
|
@@ -383,11 +398,11 @@ export function readToolDenyPatterns(toolName, projectDir, globalSettingsPath) {
|
|
|
383
398
|
catch {
|
|
384
399
|
return null;
|
|
385
400
|
}
|
|
386
|
-
const
|
|
387
|
-
if (!Array.isArray(
|
|
401
|
+
const entries = parsed?.permissions?.[kind];
|
|
402
|
+
if (!Array.isArray(entries))
|
|
388
403
|
return [];
|
|
389
404
|
const globs = [];
|
|
390
|
-
for (const entry of
|
|
405
|
+
for (const entry of entries) {
|
|
391
406
|
if (typeof entry !== "string")
|
|
392
407
|
continue;
|
|
393
408
|
const tp = parseToolPattern(entry);
|
|
@@ -552,6 +567,125 @@ export function evaluateFilePath(filePath, denyGlobs, caseInsensitive = process.
|
|
|
552
567
|
return { denied: false };
|
|
553
568
|
}
|
|
554
569
|
// ==============================================================================
|
|
570
|
+
// Project-Boundary Containment (Issue #852)
|
|
571
|
+
// ==============================================================================
|
|
572
|
+
/**
|
|
573
|
+
* Pure, algorithmic (no-regex) test: does `filePath` resolve to a location
|
|
574
|
+
* inside `projectRoot`?
|
|
575
|
+
*
|
|
576
|
+
* Issue #852 — `ctx_execute_file` previously fed its `path` argument straight
|
|
577
|
+
* into `resolve(projectRoot, path)`. Because `path.resolve` lets an *absolute*
|
|
578
|
+
* argument win outright, an agent could read any file on the host
|
|
579
|
+
* (`/home/user/secret`, `/etc/passwd`) regardless of the project root, and
|
|
580
|
+
* `../` traversal escaped just as easily. Claude Code's harness sandbox cannot
|
|
581
|
+
* inspect MCP input params, so the user approving the MCP call could not see
|
|
582
|
+
* that the path escaped the workspace. This guard re-anchors the path to the
|
|
583
|
+
* project boundary.
|
|
584
|
+
*
|
|
585
|
+
* Containment is decided on the *resolved* form. When the file (or its parent
|
|
586
|
+
* chain) exists, the symlink-canonical form is ALSO required to stay inside —
|
|
587
|
+
* this closes the symlink-escape class (a project-local `safe.log` whose
|
|
588
|
+
* realpath points at `~/.ssh/id_rsa`), mirroring `evaluateFilePath`.
|
|
589
|
+
*
|
|
590
|
+
* A path equal to the project root itself counts as inside. Comparison is
|
|
591
|
+
* case-insensitive on Windows/macOS to match those filesystems' semantics.
|
|
592
|
+
*
|
|
593
|
+
* Returns `true` when `projectRoot` is falsy (no boundary to enforce) so the
|
|
594
|
+
* caller's fail-open posture is preserved when the root cannot be resolved.
|
|
595
|
+
*/
|
|
596
|
+
export function isPathInsideProject(filePath, projectRoot, caseInsensitive = process.platform === "win32" || process.platform === "darwin") {
|
|
597
|
+
if (!projectRoot)
|
|
598
|
+
return true;
|
|
599
|
+
const root = resolve(projectRoot);
|
|
600
|
+
const lexical = resolve(projectRoot, filePath);
|
|
601
|
+
const within = (root, candidate) => {
|
|
602
|
+
let a = root;
|
|
603
|
+
let b = candidate;
|
|
604
|
+
if (caseInsensitive) {
|
|
605
|
+
a = a.toLowerCase();
|
|
606
|
+
b = b.toLowerCase();
|
|
607
|
+
}
|
|
608
|
+
if (a === b)
|
|
609
|
+
return true;
|
|
610
|
+
// `path.relative` is pure string arithmetic — no regex. A candidate inside
|
|
611
|
+
// the root yields a relative path that neither starts with `..` (escapes
|
|
612
|
+
// upward) nor is absolute (a different drive/root on Windows that cannot be
|
|
613
|
+
// expressed relatively).
|
|
614
|
+
const rel = relative(a, b);
|
|
615
|
+
if (rel === "")
|
|
616
|
+
return true;
|
|
617
|
+
if (rel === ".." || rel.startsWith(".." + sep))
|
|
618
|
+
return false;
|
|
619
|
+
if (isAbsoluteRel(rel))
|
|
620
|
+
return false;
|
|
621
|
+
return true;
|
|
622
|
+
};
|
|
623
|
+
// Lexical containment is the primary gate.
|
|
624
|
+
if (!within(root, lexical))
|
|
625
|
+
return false;
|
|
626
|
+
// Defense-in-depth: when the path (or a parent) is a symlink that points
|
|
627
|
+
// outside the project, the canonical form must ALSO stay inside. Best-effort
|
|
628
|
+
// — a not-yet-created file (ENOENT) falls back to the lexical decision above.
|
|
629
|
+
try {
|
|
630
|
+
const canonicalRoot = realpathSync(root);
|
|
631
|
+
const canonical = realpathSync(lexical);
|
|
632
|
+
if (!within(canonicalRoot, canonical))
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
/* file does not exist yet / realpath failed — lexical decision stands */
|
|
637
|
+
}
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
/** Pure helper: is a `path.relative` result an absolute path? (no regex) */
|
|
641
|
+
function isAbsoluteRel(rel) {
|
|
642
|
+
if (rel.startsWith("/"))
|
|
643
|
+
return true; // POSIX absolute
|
|
644
|
+
// Windows drive-absolute: "C:\..." or "C:/..."
|
|
645
|
+
if (rel.length >= 3 && rel[1] === ":" && (rel[2] === "\\" || rel[2] === "/")) {
|
|
646
|
+
const c = rel.charCodeAt(0);
|
|
647
|
+
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
|
648
|
+
}
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Decide whether `filePath` may be processed, given the project boundary AND
|
|
653
|
+
* the user's existing host `Read(...)` allow rules.
|
|
654
|
+
*
|
|
655
|
+
* Decision order:
|
|
656
|
+
* 1. Inside the project root → allowed (the common case; no config needed).
|
|
657
|
+
* 2. Outside the project, but matching a `permissions.allow` `Read(...)` glob
|
|
658
|
+
* the user already configured for the host → allowed. This is the
|
|
659
|
+
* principled escape hatch: a deliberate out-of-project read is expressed
|
|
660
|
+
* ONCE in the host config the user already maintains, reusing the same
|
|
661
|
+
* mechanism Claude Code itself uses to whitelist a path outside the
|
|
662
|
+
* sandbox — no context-mode-specific opt-out env that would rot into
|
|
663
|
+
* dead code.
|
|
664
|
+
* 3. Outside the project, no allow match → denied (closes the #852 escape).
|
|
665
|
+
*
|
|
666
|
+
* `allowGlobs` has the same per-settings-file shape as the deny globs returned
|
|
667
|
+
* by `readToolPermissionPatterns(toolName, "allow", …)`. Allow-matching reuses
|
|
668
|
+
* `evaluateFilePath` so absolute/`..`/symlink-canonical candidate resolution is
|
|
669
|
+
* identical to the deny path — one matcher, no divergence.
|
|
670
|
+
*
|
|
671
|
+
* Fail-open on an unknown project root (boundary cannot be computed) so the
|
|
672
|
+
* guard never blocks legitimate in-project work when resolution fails.
|
|
673
|
+
*/
|
|
674
|
+
export function evaluateProjectContainment(filePath, projectRoot, allowGlobs = [], caseInsensitive = process.platform === "win32" || process.platform === "darwin") {
|
|
675
|
+
if (isPathInsideProject(filePath, projectRoot, caseInsensitive)) {
|
|
676
|
+
return { allowed: true, reason: "inside" };
|
|
677
|
+
}
|
|
678
|
+
// Outside the project — permit only if the user explicitly allowed this path
|
|
679
|
+
// for the host Read tool. `evaluateFilePath` returns `denied:true` when a glob
|
|
680
|
+
// MATCHES, so a match here means "explicitly allowed".
|
|
681
|
+
if (allowGlobs.some((g) => g.length > 0)) {
|
|
682
|
+
const matched = evaluateFilePath(filePath, allowGlobs, caseInsensitive, projectRoot);
|
|
683
|
+
if (matched.denied)
|
|
684
|
+
return { allowed: true, reason: "allow-rule" };
|
|
685
|
+
}
|
|
686
|
+
return { allowed: false, reason: "outside" };
|
|
687
|
+
}
|
|
688
|
+
// ==============================================================================
|
|
555
689
|
// Shell-Escape Scanner
|
|
556
690
|
// ==============================================================================
|
|
557
691
|
// Regex patterns that detect shell-escape calls in non-shell languages.
|
package/build/server.js
CHANGED
|
@@ -14,10 +14,10 @@ import { PolyglotExecutor } from "./executor.js";
|
|
|
14
14
|
import { runPool } from "./runPool.js";
|
|
15
15
|
import { ContentStore, cleanupStaleDBs, cleanupStaleContentDBs } from "./store.js";
|
|
16
16
|
import { composeFetchCacheKey } from "./fetch-cache.js";
|
|
17
|
-
import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, evaluateFilePath, } from "./security.js";
|
|
17
|
+
import { readBashPolicies, evaluateCommandDenyOnly, extractShellCommands, readToolDenyPatterns, readToolPermissionPatterns, evaluateFilePath, evaluateProjectContainment, } from "./security.js";
|
|
18
18
|
import { detectRuntimes, getRuntimeSummary, getAvailableLanguages, hasBunRuntime, } from "./runtime.js";
|
|
19
19
|
import { classifyNonZeroExit } from "./exit-classify.js";
|
|
20
|
-
import { startLifecycleGuard } from "./lifecycle.js";
|
|
20
|
+
import { startLifecycleGuard, noteMcpActivity, noteRequestStart, noteRequestEnd, attachMcpActivityTap } from "./lifecycle.js";
|
|
21
21
|
import { charSafePrefix } from "./truncate.js";
|
|
22
22
|
import { describeStorageDirectorySource, ensureWritableStorageDir, formatStorageDirectoryError, hashProjectDirCanonical, hashProjectDirLegacy, resolveContentStorePath, resolveContentStorageDir, resolveDefaultSessionDir, resolveSessionDbPath, resolveSessionStorageDir, resolveStatsStorageDir, SessionDB, StorageDirectoryError, } from "./session/db.js";
|
|
23
23
|
import { purgeSession } from "./session/purge.js";
|
|
@@ -235,6 +235,10 @@ server.registerTool = (...args) => {
|
|
|
235
235
|
};
|
|
236
236
|
function wrapToolHandler(name, handler) {
|
|
237
237
|
return async (toolArgs) => {
|
|
238
|
+
// #854: mark a tool call in-flight so the bridge-child idle reaper never
|
|
239
|
+
// shuts the server down mid-execution during a long ctx_execute/batch that
|
|
240
|
+
// emits no further inbound messages. Symmetric end in finally (success+error).
|
|
241
|
+
noteRequestStart();
|
|
238
242
|
try {
|
|
239
243
|
return await handler(toolArgs);
|
|
240
244
|
}
|
|
@@ -252,6 +256,9 @@ function wrapToolHandler(name, handler) {
|
|
|
252
256
|
}
|
|
253
257
|
throw err;
|
|
254
258
|
}
|
|
259
|
+
finally {
|
|
260
|
+
noteRequestEnd();
|
|
261
|
+
}
|
|
255
262
|
};
|
|
256
263
|
}
|
|
257
264
|
// Issue #637 — when suppression is active, install the empty tools/list handler
|
|
@@ -811,6 +818,9 @@ function healCacheMidSession() {
|
|
|
811
818
|
catch { /* best effort */ }
|
|
812
819
|
}
|
|
813
820
|
function trackResponse(toolName, response) {
|
|
821
|
+
// #854: a response is activity too — refresh the bridge-child idle clock so a
|
|
822
|
+
// chatty/streaming call keeps its server alive even between inbound frames.
|
|
823
|
+
noteMcpActivity();
|
|
814
824
|
// Mid-session cache heal — one-shot, first tool call
|
|
815
825
|
healCacheMidSession();
|
|
816
826
|
// Prepend version outdated warning if needed
|
|
@@ -1034,6 +1044,50 @@ function checkNonShellDenyPolicy(code, language, toolName) {
|
|
|
1034
1044
|
}
|
|
1035
1045
|
return null;
|
|
1036
1046
|
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Issue #852 — project-boundary containment for `ctx_execute_file`.
|
|
1049
|
+
*
|
|
1050
|
+
* The harness sandbox (Claude Code, etc.) cannot inspect MCP input params, so a
|
|
1051
|
+
* user approving a `ctx_execute_file` call cannot see that its `path` escapes
|
|
1052
|
+
* the workspace. This guard refuses a `path` that resolves outside the project
|
|
1053
|
+
* root (absolute escape, `../` traversal, or symlink-out), restoring the
|
|
1054
|
+
* boundary the host believes it is enforcing.
|
|
1055
|
+
*
|
|
1056
|
+
* Escape hatch — NO bespoke opt-out env. A deliberate out-of-project read is
|
|
1057
|
+
* expressed in the SAME host config the user already maintains: a
|
|
1058
|
+
* `permissions.allow` rule like `Read(/var/log/**)`. This reuses the exact
|
|
1059
|
+
* mechanism Claude Code uses to whitelist a path outside its sandbox, so the
|
|
1060
|
+
* grant lives in one place and stays meaningful instead of rotting into a
|
|
1061
|
+
* context-mode-only env flag nobody sets.
|
|
1062
|
+
*
|
|
1063
|
+
* Fail-open on resolver failure (consistent with the other deny checks): if the
|
|
1064
|
+
* project root cannot be resolved, containment evaluates as "inside" and the
|
|
1065
|
+
* path is allowed through rather than spuriously blocking legitimate work.
|
|
1066
|
+
*/
|
|
1067
|
+
function checkProjectBoundary(filePath, toolName) {
|
|
1068
|
+
try {
|
|
1069
|
+
const projectDir = getProjectDir();
|
|
1070
|
+
const allowGlobs = readToolPermissionPatterns("Read", "allow", projectDir);
|
|
1071
|
+
const verdict = evaluateProjectContainment(filePath, projectDir, allowGlobs);
|
|
1072
|
+
if (verdict.allowed)
|
|
1073
|
+
return null;
|
|
1074
|
+
return trackResponse(toolName, {
|
|
1075
|
+
content: [{
|
|
1076
|
+
type: "text",
|
|
1077
|
+
text: `File access blocked: "${filePath}" resolves outside the project root ` +
|
|
1078
|
+
`(${projectDir}). context-mode confines ${toolName} to the workspace so it ` +
|
|
1079
|
+
`cannot be used to bypass the host's sandbox/permission controls (issue #852). ` +
|
|
1080
|
+
`To intentionally process a file outside the project, add a host allow rule, ` +
|
|
1081
|
+
`e.g. "permissions": { "allow": ["Read(${filePath})"] } in your settings.`,
|
|
1082
|
+
}],
|
|
1083
|
+
isError: true,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
1087
|
+
// Fail-open — resolver failure must not block legitimate in-project work.
|
|
1088
|
+
}
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1037
1091
|
/**
|
|
1038
1092
|
* Check a file path against Read deny patterns.
|
|
1039
1093
|
* Returns an error ToolResult if denied, or null if allowed.
|
|
@@ -1388,7 +1442,9 @@ export async function runBatchCommands(commands, opts, executor) {
|
|
|
1388
1442
|
// Tool: execute
|
|
1389
1443
|
// ─────────────────────────────────────────────────────────
|
|
1390
1444
|
server.registerTool("ctx_execute", {
|
|
1391
|
-
|
|
1445
|
+
// #852: surface code execution in the host approval prompt's title (the
|
|
1446
|
+
// only server-controlled field the MCP permission UI renders besides args).
|
|
1447
|
+
title: "Run code in a sandbox (executes the supplied code)",
|
|
1392
1448
|
// #846: runs arbitrary code in a sandbox with full network access.
|
|
1393
1449
|
annotations: {
|
|
1394
1450
|
readOnlyHint: false,
|
|
@@ -1741,7 +1797,10 @@ function intentSearch(stdout, intent, source, maxResults = 5) {
|
|
|
1741
1797
|
// Tool: execute_file
|
|
1742
1798
|
// ─────────────────────────────────────────────────────────
|
|
1743
1799
|
server.registerTool("ctx_execute_file", {
|
|
1744
|
-
|
|
1800
|
+
// #852: the host's MCP approval prompt renders only the tool name/title +
|
|
1801
|
+
// raw args — the title is the one server-controlled signal, so make it
|
|
1802
|
+
// unambiguously announce code execution + file read for the reviewer.
|
|
1803
|
+
title: "Run code over a file (executes code, reads the given path)",
|
|
1745
1804
|
// #846: runs arbitrary code over a file in a sandbox with full network access.
|
|
1746
1805
|
annotations: {
|
|
1747
1806
|
readOnlyHint: false,
|
|
@@ -1803,6 +1862,12 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
|
|
|
1803
1862
|
"returns only matching sections via BM25 search instead of truncated output."),
|
|
1804
1863
|
}),
|
|
1805
1864
|
}, async ({ path, language, code, timeout, intent }) => {
|
|
1865
|
+
// Security (#852): confine the processed file to the project root so
|
|
1866
|
+
// ctx_execute_file cannot be used to escape the host's sandbox/permission
|
|
1867
|
+
// controls. Runs before the deny-glob check — boundary first, then policy.
|
|
1868
|
+
const boundaryDenied = checkProjectBoundary(path, "ctx_execute_file");
|
|
1869
|
+
if (boundaryDenied)
|
|
1870
|
+
return boundaryDenied;
|
|
1806
1871
|
// Security: check file path against Read deny patterns
|
|
1807
1872
|
const pathDenied = checkFilePathDenyPolicy(path, "ctx_execute_file");
|
|
1808
1873
|
if (pathDenied)
|
|
@@ -4384,6 +4449,10 @@ async function main() {
|
|
|
4384
4449
|
startLifecycleGuard({ onShutdown: () => gracefulShutdown() });
|
|
4385
4450
|
const transport = new StdioServerTransport();
|
|
4386
4451
|
await server.connect(transport);
|
|
4452
|
+
// #854: refresh the bridge-child idle clock on each inbound MCP message so an
|
|
4453
|
+
// abandoned bridge child (CONTEXT_MODE_BRIDGE_DEPTH>0) self-terminates instead
|
|
4454
|
+
// of accumulating under a long-lived Pi/omp parent. Best-effort; no stdin touch.
|
|
4455
|
+
attachMcpActivityTap(transport);
|
|
4387
4456
|
// Write MCP readiness sentinel (#230)
|
|
4388
4457
|
try {
|
|
4389
4458
|
writeFileSync(mcpSentinel, String(process.pid));
|
package/build/session/extract.js
CHANGED
|
@@ -1439,6 +1439,72 @@ const ROLE_MIN_CHARS = 8;
|
|
|
1439
1439
|
const ROLE_MAX_CHARS = 120;
|
|
1440
1440
|
const TWO_LEXICAL_TOKENS_PATTERN = /\p{L}+\s+\p{L}+/u;
|
|
1441
1441
|
const CONTINUOUS_LETTER_RUN_PATTERN = /\p{L}{6,}/u;
|
|
1442
|
+
// Issue #856 — persona / standing-directive cue gate.
|
|
1443
|
+
//
|
|
1444
|
+
// The structural test below ("two lexical tokens OR a 6-codepoint letter run,
|
|
1445
|
+
// 8..120 chars, no '?', no clause separator") is intentionally coarse and
|
|
1446
|
+
// matches ANY short declarative sentence. That let casual conversational
|
|
1447
|
+
// acknowledgements ("that's fine for now", "go with the second option") freeze
|
|
1448
|
+
// as a priority-3 `role`, which the Pi adapter then re-injected as a standing
|
|
1449
|
+
// behavioral_directive every turn → do-nothing loop.
|
|
1450
|
+
//
|
|
1451
|
+
// A genuine role/behavioral prompt always LEADS with a persona declaration
|
|
1452
|
+
// ("You are X", "Tu es X", "あなたは…", "你是…") or a standing-directive verb
|
|
1453
|
+
// ("always respond…", "act as…"). Casual phrases never do, so we require that
|
|
1454
|
+
// cue as a NECESSARY condition. This preserves legitimate role persistence
|
|
1455
|
+
// (issue #535 multilingual corpus) while killing the casual-phrase loop.
|
|
1456
|
+
//
|
|
1457
|
+
// ALGORITHMIC ONLY — pure lowercase + prefix membership, no regex (project
|
|
1458
|
+
// hard rule). Multilingual openers are matched by `startsWith` on the
|
|
1459
|
+
// normalized first clause; leading conversational filler tokens are stripped
|
|
1460
|
+
// by array operations before the check.
|
|
1461
|
+
const ROLE_FILLER_TOKENS = new Set([
|
|
1462
|
+
"ok", "okay", "sure", "yeah", "yep", "yup", "alright", "fine",
|
|
1463
|
+
"well", "so", "hmm", "right", "please",
|
|
1464
|
+
]);
|
|
1465
|
+
// Second-person persona openers across the supported-language corpus
|
|
1466
|
+
// (issue #535 multilingual role test set) plus common English persona framings.
|
|
1467
|
+
const ROLE_PERSONA_PREFIXES = [
|
|
1468
|
+
"you are", "you're", "your role", "you will be", "you act", "you will act",
|
|
1469
|
+
"act as", "act like", "behave as", "behave like", "imagine you", "pretend you",
|
|
1470
|
+
"assume the role", "take the role", "play the role", "respond as",
|
|
1471
|
+
"tu es", "tu est", "vous etes", "vous êtes", // French
|
|
1472
|
+
"sen ", "siz ", // Turkish (Sen kıdemli…)
|
|
1473
|
+
"eres ", "tú eres", "usted es", // Spanish (Eres…)
|
|
1474
|
+
"ты ", "вы ", // Russian (Ты опытный…)
|
|
1475
|
+
"あなたは", "君は", "お前は", "あなたが", // Japanese (あなたは…)
|
|
1476
|
+
"你是", "您是", // Chinese (你是…)
|
|
1477
|
+
"तुम ", "आप ", "तू ", // Hindi (तुम…)
|
|
1478
|
+
"أنت ", "انت ", "أنتَ ", // Arabic (أنت…)
|
|
1479
|
+
];
|
|
1480
|
+
// Standing-directive verb openers — imperative behavioral rules that should
|
|
1481
|
+
// persist ("always respond in TypeScript", "never use emojis").
|
|
1482
|
+
const ROLE_DIRECTIVE_PREFIXES = [
|
|
1483
|
+
"always ", "never ", "respond ", "reply ", "answer ", "speak ",
|
|
1484
|
+
"write ", "prefer ", "format ", "output ", "communicate ", "use only ",
|
|
1485
|
+
];
|
|
1486
|
+
function hasRoleCue(firstClause) {
|
|
1487
|
+
const lower = firstClause.toLowerCase().trim();
|
|
1488
|
+
if (!lower)
|
|
1489
|
+
return false;
|
|
1490
|
+
// Strip leading conversational filler tokens via array ops (no regex).
|
|
1491
|
+
const tokens = lower.split(" ").filter((t) => t.length > 0);
|
|
1492
|
+
while (tokens.length > 0 && ROLE_FILLER_TOKENS.has(tokens[0])) {
|
|
1493
|
+
tokens.shift();
|
|
1494
|
+
}
|
|
1495
|
+
const normalized = tokens.join(" ");
|
|
1496
|
+
if (!normalized)
|
|
1497
|
+
return false;
|
|
1498
|
+
for (const prefix of ROLE_PERSONA_PREFIXES) {
|
|
1499
|
+
if (normalized.startsWith(prefix))
|
|
1500
|
+
return true;
|
|
1501
|
+
}
|
|
1502
|
+
for (const prefix of ROLE_DIRECTIVE_PREFIXES) {
|
|
1503
|
+
if (normalized.startsWith(prefix))
|
|
1504
|
+
return true;
|
|
1505
|
+
}
|
|
1506
|
+
return false;
|
|
1507
|
+
}
|
|
1442
1508
|
function looksLikeRole(trimmed) {
|
|
1443
1509
|
// Role prompts are persona-prefix shaped: the FIRST SENTENCE declares the
|
|
1444
1510
|
// role (e.g. "You are a senior backend engineer. <long context...>").
|
|
@@ -1457,6 +1523,10 @@ function looksLikeRole(trimmed) {
|
|
|
1457
1523
|
const codepointLength = [...firstClause].length;
|
|
1458
1524
|
if (codepointLength < ROLE_MIN_CHARS || codepointLength > ROLE_MAX_CHARS)
|
|
1459
1525
|
return false;
|
|
1526
|
+
// Issue #856 — require a persona / standing-directive cue so casual
|
|
1527
|
+
// conversational acknowledgements do not freeze as a role directive.
|
|
1528
|
+
if (!hasRoleCue(firstClause))
|
|
1529
|
+
return false;
|
|
1460
1530
|
return (TWO_LEXICAL_TOKENS_PATTERN.test(firstClause) ||
|
|
1461
1531
|
CONTINUOUS_LETTER_RUN_PATTERN.test(firstClause));
|
|
1462
1532
|
}
|