diff --git a/agent-network/src/grok-copresence-disclosure.test.ts b/agent-network/src/grok-copresence-disclosure.test.ts index 17b0161ee..fe17f866d 100644 --- a/agent-network/src/grok-copresence-disclosure.test.ts +++ b/agent-network/src/grok-copresence-disclosure.test.ts @@ -22,8 +22,23 @@ describe("grok co-presence disclosure", () => { expect(text).not.toContain("No filesystem, shell, web,"); }); + test("repo-read profile reports only sandboxed project reads", () => { + const disclosure = grokCopresenceDisclosure(["Read", "Grep", "Glob"], "new"); + const text = disclosure.lines.join("\n"); + expect(disclosure.profile).toBe("repo-read"); + expect(text).toContain("[todo_write,search_tool,use_tool,read_file,grep,list_dir]"); + expect(text).toContain("strict sandbox"); + expect(text).toContain("essential system paths"); + expect(text).toContain("protected credential paths"); + expect(text).toContain("shell, writes, web/media"); + expect(text).not.toContain("web_search is enabled"); + }); + test("near-match tools are disclosed as invalid rather than a reviewed profile", () => { - for (const tools of [["websearch"], ["WebSearch", "Bash"], [" WebSearch"]]) { + for (const tools of [ + ["websearch"], ["WebSearch", "Bash"], [" WebSearch"], + ["Read", "Glob", "Grep"], ["Read", "Grep"], + ]) { const disclosure = grokCopresenceDisclosure(tools, "configured"); expect(disclosure.profile).toBe("invalid"); expect(disclosure.lines.join("\n")).toContain("startup will fail closed"); diff --git a/agent-network/src/grok-copresence-disclosure.ts b/agent-network/src/grok-copresence-disclosure.ts index 60ded425d..f51ffb83f 100644 --- a/agent-network/src/grok-copresence-disclosure.ts +++ b/agent-network/src/grok-copresence-disclosure.ts @@ -1,12 +1,12 @@ export type GrokCopresenceSessionDisclosure = "configured" | "new" | "resume"; export type GrokCopresenceDisclosure = { - profile: "commhub-only" | "x-search" | "invalid"; + profile: "commhub-only" | "x-search" | "repo-read" | "invalid"; lines: readonly string[]; }; /** - * Describe only the two exact tool profiles accepted by the pinned Grok TUI + * Describe only the exact tool profiles accepted by the pinned Grok TUI * runtime. This is deliberately exact: a near-miss must never be presented as * either reviewed capability set. */ @@ -16,6 +16,10 @@ export function grokCopresenceDisclosure( ): GrokCopresenceDisclosure { const configured = Array.isArray(tools) ? tools : []; const xSearch = configured.length === 1 && configured[0] === "WebSearch"; + const repoRead = configured.length === 3 + && configured[0] === "Read" + && configured[1] === "Grep" + && configured[2] === "Glob"; const defaultProfile = configured.length === 0; const lines: string[] = []; @@ -24,6 +28,10 @@ export function grokCopresenceDisclosure( profile = "x-search"; lines.push("Configured profile: x-search; fixed tools: [todo_write,search_tool,use_tool,web_search]."); lines.push("General web_search is enabled; WebFetch, filesystem, shell, media, project/host MCP, and subagents remain unavailable."); + } else if (repoRead) { + profile = "repo-read"; + lines.push("Configured profile: repo-read; fixed tools: [todo_write,search_tool,use_tool,read_file,grep,list_dir]."); + lines.push("Read-only filesystem access uses Grok's strict sandbox (project tree plus essential system paths); protected credential paths, shell, writes, web/media, and subagents remain unavailable."); } else if (defaultProfile) { profile = "commhub-only"; lines.push("Configured profile: commhub-only; fixed tools: [todo_write,search_tool,use_tool]."); @@ -31,7 +39,7 @@ export function grokCopresenceDisclosure( } else { profile = "invalid"; lines.push("Configured tools do not match a supported exact Grok co-presence profile; startup will fail closed."); - lines.push("Supported profiles are [] and [WebSearch]; custom or near-match tool names are not accepted."); + lines.push('Supported profiles are [], [WebSearch], and [Read,Grep,Glob]; custom, reordered, or near-match tool names are not accepted.'); } if (session === "new") { diff --git a/agent-node/src/cli.ts b/agent-node/src/cli.ts index 172a5dfc6..0957bfc7e 100644 --- a/agent-node/src/cli.ts +++ b/agent-node/src/cli.ts @@ -71,6 +71,7 @@ import { resolveGrokAcpTimeout } from "./runtime/grok-build-acp/timeout-resolve" import { GROK_COPRESENCE_PROFILE_ENV, selectGrokCopresenceCapabilityProfile, + selectGrokCopresenceSandboxProfile, } from "./runtime/grok-copresence/profile-selection"; import { defaultNpmInstall, @@ -549,7 +550,7 @@ if (GROK_COPRESENCE) { console.warn( `[agent-node] EXPERIMENTAL/DANGEROUS grok-build-cli co-presence is enabled ` + `(process profile=${GROK_COPRESENCE_CAPABILITY_PROFILE}); the shared human TUI must receive ` - + "tasks only from trusted senders. MCP is CommHub-only; WebSearch is available only in the explicit x-search profile.", + + "tasks only from trusted senders. MCP is CommHub-only; WebSearch and repo reads are available only in their explicit profiles.", ); } // Default 50 turns. The old default of 5 was way too low — Claude Agent SDK @@ -3569,7 +3570,7 @@ async function ensureGrokCopresenceRuntime(): Promise { ); } // The generic tools option is not forwarded. It was already reduced at - // process boot to one of two exact profiles; any other value failed before + // process boot to one exact runtime-owned profile; any other value failed before // the runtime module was loaded. if ( MAX_TURNS_CLI !== undefined @@ -3768,9 +3769,15 @@ async function ensureGrokCopresenceRuntime(): Promise { alias: currentAlias(), model: MODEL || undefined, agentProfile: grokCliHome.copresenceAgentProfile, - // The runtime always approves its fixed three-tool profile. Filesystem, - // shell, web, host/project MCP and subagent capabilities remain absent. - sandboxProfile: grokCliHome.workspaceProfile, + // Repo-read is the only profile with filesystem tools. Pinned 0.2.93 + // documents workspace as read-everywhere, so repo-read must use the + // kernel-enforced strict base (CWD + essential system paths). A resumed + // workspace session cannot change sandbox and therefore requires an + // explicit new session before repo-read can start. + sandboxProfile: selectGrokCopresenceSandboxProfile( + GROK_COPRESENCE_CAPABILITY_PROFILE, + grokCliHome, + ), protectedPaths: [ grokCliHome.home, grokCliHome.commhubCredentialDir || "", @@ -5876,7 +5883,7 @@ if (AUTH_TOKEN) { } // #101 fix: log resolved toolset shape. Co-presence reduces the generic config -// to one of two runtime-owned process profiles verified for the pinned TUI. +// to one runtime-owned process profile verified for the pinned TUI. const requestedToolsSummary = Array.isArray(TOOLS) ? (TOOLS.length ? `[${TOOLS.join(",")}]` : "(none)") @@ -5884,7 +5891,9 @@ const requestedToolsSummary = log(` tools: ${GROK_COPRESENCE ? GROK_COPRESENCE_CAPABILITY_PROFILE === "x-search" ? "fixed x-search profile [todo_write,search_tool,use_tool,web_search] (general web; no web-fetch/filesystem/shell/media/subagents)" - : "fixed commhub-only profile [todo_write,search_tool,use_tool] (no filesystem/shell/web/media/subagents)" + : GROK_COPRESENCE_CAPABILITY_PROFILE === "repo-read" + ? "fixed repo-read profile [todo_write,search_tool,use_tool,read_file,grep,list_dir] (strict CWD reads; no shell/write/web/media/subagents)" + : "fixed commhub-only profile [todo_write,search_tool,use_tool] (no filesystem/shell/web/media/subagents)" : requestedToolsSummary}`); log(` channels:${[ TELEGRAM_CHANNELS.length ? `telegram(${TELEGRAM_CHANNELS.map(ch => ch.dir).join(",")})` : "", diff --git a/agent-node/src/runtime/grok-build-cli-home.test.ts b/agent-node/src/runtime/grok-build-cli-home.test.ts index 70eac769d..3a33ce419 100644 --- a/agent-node/src/runtime/grok-build-cli-home.test.ts +++ b/agent-node/src/runtime/grok-build-cli-home.test.ts @@ -146,6 +146,7 @@ describe("prepareGrokCliHome", () => { expect(first).toEqual(second); expect(first.readOnlyProfile).toMatch(/^anet-[a-f0-9]{24}-read-only$/); + expect(first.strictProfile).toMatch(/^anet-[a-f0-9]{24}-strict$/); expect(first.authPath).toBe(join(sourceHome, "auth.json")); expect(first.oidcIssuer).toBe("https://auth.example.test"); expect(first.oidcClientId).toBe("client-123"); @@ -165,6 +166,7 @@ describe("prepareGrokCliHome", () => { expect(sandbox).toContain(secretDir); expect(sandbox).toContain(join(sourceHome, "auth.json")); expect(sandbox).toContain('extends = "read-only"'); + expect(sandbox).toContain('extends = "strict"'); expect(sandbox).toContain('extends = "workspace"'); }); @@ -871,6 +873,12 @@ describe("prepareGrokCliHome", () => { expect(statSync(stagedEnv).mode & 0o777).toBe(0o600); expect(config).toContain('COMMHUB_ALIAS = "指挥狗"'); expect(config).not.toContain("ntok_test"); + const strictSandbox = readFileSync(join(stateHome, "sandbox.toml"), "utf8"); + const strictBlock = strictSandbox.split('extends = "strict"')[1]?.split("[profiles.")[0] || ""; + const command = config.match(/^command = (.+)$/m)?.[1] || ""; + expect(strictBlock).toContain(JSON.stringify(sourceHome)); + expect(strictBlock).toContain(JSON.stringify(dirname(stagedEnv))); + expect(strictBlock).toContain(command); expect(readFileSync(join(stateHome, "requirements.toml"), "utf8")) .toBe("[ui]\ndisable_bypass_permissions_mode = false\n"); const trustStore = join(stateHome, "trusted_folders.toml"); diff --git a/agent-node/src/runtime/grok-build-cli-home.ts b/agent-node/src/runtime/grok-build-cli-home.ts index aa8114b58..e9dfd0aa5 100644 --- a/agent-node/src/runtime/grok-build-cli-home.ts +++ b/agent-node/src/runtime/grok-build-cli-home.ts @@ -109,6 +109,7 @@ export interface GrokCliHome { oidcIssuer?: string; oidcClientId?: string; readOnlyProfile: string; + strictProfile: string; workspaceProfile: string; /** Absolute runtime-owned profile passed through the TUI-effective --agent flag. */ copresenceAgentProfile?: string; @@ -1399,6 +1400,7 @@ export function prepareGrokCliHome(opts: PrepareGrokCliHomeOptions): GrokCliHome } const readOnlyProfile = `${profileId}-read-only`; + const strictProfile = `${profileId}-strict`; const workspaceProfile = `${profileId}-workspace`; const existingSecretPaths = resolvedDenyPaths.filter((path) => existsSync(path)); if (!existingSecretPaths.length) { @@ -1509,7 +1511,7 @@ export function prepareGrokCliHome(opts: PrepareGrokCliHomeOptions): GrokCliHome if (opts.useLeader === true) { // This runtime deliberately uses the pinned CLI's always-approve mode for - // its fixed three-tool profile. Keep the user-tier requirements file from + // its exact runtime-owned profile. Keep the user-tier requirements file from // accidentally disabling that mode. writeGeneratedFile(join(stateHome, "requirements.toml"), [ "[ui]", @@ -1523,6 +1525,15 @@ export function prepareGrokCliHome(opts: PrepareGrokCliHomeOptions): GrokCliHome 'extends = "read-only"', `deny = [${denyToml}]`, "", + `[profiles.${JSON.stringify(strictProfile)}]`, + 'extends = "strict"', + `read_only = [${[ + sourceHome, + ...(commhubMcp ? [commhubMcp.command] : []), + ...(stagedCommhubMcp ? [stagedCommhubMcp.credentialDir] : []), + ].map((path) => JSON.stringify(path)).join(", ")}]`, + `deny = [${denyToml}]`, + "", `[profiles.${JSON.stringify(workspaceProfile)}]`, 'extends = "workspace"', `deny = [${denyToml}]`, @@ -1549,6 +1560,7 @@ export function prepareGrokCliHome(opts: PrepareGrokCliHomeOptions): GrokCliHome ...(oidcIssuer && oidcClientId ? { oidcIssuer, oidcClientId } : {}), ...(stagedCommhubMcp ? { commhubCredentialDir: stagedCommhubMcp.credentialDir } : {}), readOnlyProfile, + strictProfile, workspaceProfile, ...(opts.useLeader === true ? { copresenceAgentProfile } : {}), }; diff --git a/agent-node/src/runtime/grok-copresence/policy.ts b/agent-node/src/runtime/grok-copresence/policy.ts index 6e36e7635..dde513951 100644 --- a/agent-node/src/runtime/grok-copresence/policy.ts +++ b/agent-node/src/runtime/grok-copresence/policy.ts @@ -15,16 +15,19 @@ import { readPinnedGrokCopresenceCapabilityProfile } from "./profile-selection"; * therefore uses one runtime-owned agent profile and verifies the effective * request inventory independently. The two MCP dispatcher tools are useful * only because startup separately proves that exactly one runtime-owned - * `commhub` server was discovered. Filesystem, process, web/media, subagent, - * and scheduler tools remain absent. + * `commhub` server was discovered. The repo-read profile adds only the three + * read-only project tools; process, write, web/media, subagent, and scheduler + * tools remain absent, while protected credential paths stay hard-denied. */ export const GROK_COPRESENCE_CAPABILITY_PROFILE = readPinnedGrokCopresenceCapabilityProfile(); export const GROK_COPRESENCE_WEB_SEARCH_ENABLED = GROK_COPRESENCE_CAPABILITY_PROFILE === "x-search"; +export const GROK_COPRESENCE_REPO_READ_ENABLED = GROK_COPRESENCE_CAPABILITY_PROFILE === "repo-read"; export const GROK_COPRESENCE_EFFECTIVE_TOOLS = Object.freeze([ "todo_write", "search_tool", "use_tool", ...(GROK_COPRESENCE_WEB_SEARCH_ENABLED ? ["web_search"] : []), + ...(GROK_COPRESENCE_REPO_READ_ENABLED ? ["read_file", "grep", "list_dir"] : []), ]); export const GROK_COPRESENCE_AGENT_NAME = "anet-copresence-preview"; @@ -44,7 +47,9 @@ export function renderGrokCopresenceAgentProfile(): string { "---", GROK_COPRESENCE_WEB_SEARCH_ENABLED ? `${GROK_COPRESENCE_PROFILE_MARKER}: Answer the current user directly. The runtime-owned outbound-only commhub MCP integration and general web_search are available; do not claim inbound CommHub, lifecycle/presence ownership, filesystem, shell, web-fetch/media, or subagent access. Web search is not an x.com-only network sandbox.` - : `${GROK_COPRESENCE_PROFILE_MARKER}: Answer the current user directly. Only the runtime-owned outbound-only commhub MCP integration is available; do not claim inbound CommHub, lifecycle/presence ownership, filesystem, shell, web/media, or subagent access.`, + : GROK_COPRESENCE_REPO_READ_ENABLED + ? `${GROK_COPRESENCE_PROFILE_MARKER}: Answer the current user directly. The runtime-owned outbound-only commhub MCP integration plus read_file, grep, and list_dir are available under Grok's strict sandbox: project tree plus essential system paths only; protected credential paths remain denied. Do not claim shell, write/edit, web/media, lifecycle/presence ownership, or subagent access.` + : `${GROK_COPRESENCE_PROFILE_MARKER}: Answer the current user directly. Only the runtime-owned outbound-only commhub MCP integration is available; do not claim inbound CommHub, lifecycle/presence ownership, filesystem, shell, web/media, or subagent access.`, "", ].join("\n"); } diff --git a/agent-node/src/runtime/grok-copresence/profile-process-probe.ts b/agent-node/src/runtime/grok-copresence/profile-process-probe.ts index 1bc8c76ed..06d58f295 100644 --- a/agent-node/src/runtime/grok-copresence/profile-process-probe.ts +++ b/agent-node/src/runtime/grok-copresence/profile-process-probe.ts @@ -4,6 +4,12 @@ import { GROK_COPRESENCE_EFFECTIVE_TOOLS, renderGrokCopresenceAgentProfile, } from "./policy"; +import { selectGrokCopresenceSandboxProfile } from "./profile-selection"; + +const sandboxProfile = selectGrokCopresenceSandboxProfile( + GROK_COPRESENCE_CAPABILITY_PROFILE, + { workspaceProfile: "anet-test232-workspace", strictProfile: "anet-test232-strict" }, +); const args = buildGrokCopresenceArgs({ cwd: "/workspace/project", @@ -11,7 +17,7 @@ const args = buildGrokCopresenceArgs({ resume: false, leaderSocket: "/tmp/anet-test232/leader.sock", agentProfile: "/runtime/anet-copresence-preview.md", - sandboxProfile: "anet-test232-workspace", + sandboxProfile, protectedPaths: ["/runtime/private"], }); const automaticTool = (tool: string, turnOwner: "human" | "network") => isGrokPreviewAutomaticResolution({ @@ -33,14 +39,16 @@ const automaticTool = (tool: string, turnOwner: "human" | "network") => isGrokPr process.stdout.write(JSON.stringify({ profile: GROK_COPRESENCE_CAPABILITY_PROFILE, tools: GROK_COPRESENCE_EFFECTIVE_TOOLS, + sandboxProfile, args, renderedProfile: renderGrokCopresenceAgentProfile(), - automaticWebSearch: { - human: automaticTool("web_search", "human"), - network: automaticTool("web_search", "network"), - }, - webSearchNearMisses: [ + automaticTools: Object.fromEntries(GROK_COPRESENCE_EFFECTIVE_TOOLS.map((tool) => [tool, { + human: automaticTool(tool, "human"), + network: automaticTool(tool, "network"), + }])), + toolNearMisses: [ "web_search2", "WebSearch", " web_search", "web_search ", "web-search", "web_search\n", "web_search", "not_web_search", + "read_file2", "Read", "read-file", " grep", "list_dir ", "list_directory", ].map((tool) => [tool, automaticTool(tool, "network")]), }) + "\n"); diff --git a/agent-node/src/runtime/grok-copresence/profile-process.test.ts b/agent-node/src/runtime/grok-copresence/profile-process.test.ts index 7d13ccf47..d0bf43e58 100644 --- a/agent-node/src/runtime/grok-copresence/profile-process.test.ts +++ b/agent-node/src/runtime/grok-copresence/profile-process.test.ts @@ -6,13 +6,14 @@ import { GROK_COPRESENCE_PROFILE_ENV } from "./profile-selection"; interface ProbeResult { profile: string; tools: string[]; + sandboxProfile: string; args: string[]; renderedProfile: string; - automaticWebSearch: { human: boolean; network: boolean }; - webSearchNearMisses: Array<[string, boolean]>; + automaticTools: Record; + toolNearMisses: Array<[string, boolean]>; } -function probe(profile: "commhub-only" | "x-search"): ProbeResult { +function probe(profile: "commhub-only" | "x-search" | "repo-read"): ProbeResult { const child = spawnSync(process.execPath, [join(import.meta.dir, "profile-process-probe.ts")], { encoding: "utf8", env: { ...process.env, [GROK_COPRESENCE_PROFILE_ENV]: profile }, @@ -22,33 +23,56 @@ function probe(profile: "commhub-only" | "x-search"): ProbeResult { } describe("Grok co-presence profile is pinned for the whole process", () => { - test("same input yields two exact, non-overlapping process capabilities", () => { + test("same input yields three exact, non-overlapping process capabilities", () => { const restricted = probe("commhub-only"); const xSearch = probe("x-search"); + const repoRead = probe("repo-read"); expect(restricted.profile).toBe("commhub-only"); expect(restricted.tools).toEqual(["todo_write", "search_tool", "use_tool"]); + expect(restricted.sandboxProfile).toBe("anet-test232-workspace"); expect(restricted.args).toContain("--disable-web-search"); expect(restricted.renderedProfile).not.toContain(" - web_search"); - expect(restricted.automaticWebSearch).toEqual({ human: false, network: false }); + expect(restricted.automaticTools).toEqual({ + todo_write: { human: true, network: true }, + search_tool: { human: true, network: true }, + use_tool: { human: true, network: true }, + }); expect(xSearch.profile).toBe("x-search"); expect(xSearch.tools).toEqual(["todo_write", "search_tool", "use_tool", "web_search"]); + expect(xSearch.sandboxProfile).toBe("anet-test232-workspace"); expect(xSearch.args).not.toContain("--disable-web-search"); expect(xSearch.renderedProfile).toContain(" - web_search"); expect(xSearch.renderedProfile).toContain("not an x.com-only network sandbox"); - expect(xSearch.automaticWebSearch).toEqual({ human: true, network: true }); - expect(xSearch.webSearchNearMisses).toEqual([ + expect(xSearch.automaticTools.web_search).toEqual({ human: true, network: true }); + + expect(repoRead.profile).toBe("repo-read"); + expect(repoRead.tools).toEqual([ + "todo_write", "search_tool", "use_tool", "read_file", "grep", "list_dir", + ]); + expect(repoRead.sandboxProfile).toBe("anet-test232-strict"); + expect(repoRead.args).toContain("--disable-web-search"); + expect(repoRead.renderedProfile).toContain("strict sandbox"); + for (const tool of ["read_file", "grep", "list_dir"]) { + expect(repoRead.automaticTools[tool]).toEqual({ human: true, network: true }); + } + + expect(repoRead.toolNearMisses).toEqual([ ["web_search2", false], ["WebSearch", false], [" web_search", false], ["web_search ", false], ["web-search", false], ["web_search\n", false], ["web_search", false], ["not_web_search", false], + ["read_file2", false], ["Read", false], ["read-file", false], [" grep", false], + ["list_dir ", false], ["list_directory", false], ]); - for (const result of [restricted, xSearch]) { + for (const result of [restricted, xSearch, repoRead]) { const denied = result.args.flatMap((value, index) => result.args[index - 1] === "--deny" ? [value] : []); expect(denied).toContain("Bash"); expect(denied).toContain("Write"); expect(denied).toContain("WebFetch"); + expect(denied).toContain("Read(/runtime/private)"); + expect(denied).toContain("Grep(/runtime/private/**)"); } }); }); diff --git a/agent-node/src/runtime/grok-copresence/profile-selection.test.ts b/agent-node/src/runtime/grok-copresence/profile-selection.test.ts index 864e9d6ad..07a4e8ee1 100644 --- a/agent-node/src/runtime/grok-copresence/profile-selection.test.ts +++ b/agent-node/src/runtime/grok-copresence/profile-selection.test.ts @@ -3,15 +3,18 @@ import { GROK_COPRESENCE_PROFILE_ENV, readPinnedGrokCopresenceCapabilityProfile, selectGrokCopresenceCapabilityProfile, + selectGrokCopresenceSandboxProfile, } from "./profile-selection"; describe("Grok co-presence process capability profile", () => { - test("accepts only the two exact startup profiles", () => { + test("accepts only the exact startup profiles", () => { expect(selectGrokCopresenceCapabilityProfile(undefined)).toBe("commhub-only"); expect(selectGrokCopresenceCapabilityProfile([])).toBe("commhub-only"); expect(selectGrokCopresenceCapabilityProfile(["WebSearch"])).toBe("x-search"); + expect(selectGrokCopresenceCapabilityProfile(["Read", "Grep", "Glob"])).toBe("repo-read"); for (const tools of [ ["web_search"], ["WebSearch", "WebFetch"], ["WebSearch "], ["all"], ["Read"], + ["Read", "Glob", "Grep"], ["Read", "Grep"], ["Read", "Grep", "Glob", "WebSearch"], ]) { expect(() => selectGrokCopresenceCapabilityProfile(tools)).toThrow("exact tools profile"); } @@ -24,9 +27,19 @@ describe("Grok co-presence process capability profile", () => { expect(readPinnedGrokCopresenceCapabilityProfile({ [GROK_COPRESENCE_PROFILE_ENV]: "x-search", })).toBe("x-search"); + expect(readPinnedGrokCopresenceCapabilityProfile({ + [GROK_COPRESENCE_PROFILE_ENV]: "repo-read", + })).toBe("repo-read"); expect(readPinnedGrokCopresenceCapabilityProfile({})).toBe("commhub-only"); expect(() => readPinnedGrokCopresenceCapabilityProfile({ [GROK_COPRESENCE_PROFILE_ENV]: "human-turn", })).toThrow("invalid"); }); + + test("uses the strict sandbox only for repo-read", () => { + const profiles = { workspaceProfile: "anet-workspace", strictProfile: "anet-strict" }; + expect(selectGrokCopresenceSandboxProfile("commhub-only", profiles)).toBe("anet-workspace"); + expect(selectGrokCopresenceSandboxProfile("x-search", profiles)).toBe("anet-workspace"); + expect(selectGrokCopresenceSandboxProfile("repo-read", profiles)).toBe("anet-strict"); + }); }); diff --git a/agent-node/src/runtime/grok-copresence/profile-selection.ts b/agent-node/src/runtime/grok-copresence/profile-selection.ts index 130387dee..5bb08521b 100644 --- a/agent-node/src/runtime/grok-copresence/profile-selection.ts +++ b/agent-node/src/runtime/grok-copresence/profile-selection.ts @@ -1,19 +1,30 @@ export const GROK_COPRESENCE_PROFILE_ENV = "ANET_INTERNAL_GROK_COPRESENCE_PROFILE"; -export type GrokCopresenceCapabilityProfile = "commhub-only" | "x-search"; +export type GrokCopresenceCapabilityProfile = "commhub-only" | "x-search" | "repo-read"; + +export interface GrokCopresenceSandboxProfiles { + workspaceProfile: string; + strictProfile: string; +} /** * Resolve once while agent-node boots. Co-presence deliberately does not - * accept a general tool allowlist: the only opt-in is the reviewed WebSearch - * profile, represented by the CLI's canonical Claude-style tool name. + * accept a general tool allowlist. Each opt-in is one reviewed, exact profile + * represented by canonical Claude-style tool names. */ export function selectGrokCopresenceCapabilityProfile( tools: readonly string[] | undefined, ): GrokCopresenceCapabilityProfile { if (tools === undefined || tools.length === 0) return "commhub-only"; if (tools.length === 1 && tools[0] === "WebSearch") return "x-search"; + if ( + tools.length === 3 + && tools[0] === "Read" + && tools[1] === "Grep" + && tools[2] === "Glob" + ) return "repo-read"; throw new Error( - 'grok copresence supports only the exact tools profile ["WebSearch"] or an omitted tools field', + 'grok copresence supports only the exact tools profiles ["WebSearch"], ["Read","Grep","Glob"], or an omitted tools field', ); } @@ -23,6 +34,17 @@ export function readPinnedGrokCopresenceCapabilityProfile( ): GrokCopresenceCapabilityProfile { const value = env[GROK_COPRESENCE_PROFILE_ENV]; if (value === undefined || value === "") return "commhub-only"; - if (value === "commhub-only" || value === "x-search") return value; + if (value === "commhub-only" || value === "x-search" || value === "repo-read") return value; throw new Error("grok copresence process capability profile is invalid"); } + +/** + * Repo reads require Grok's kernel-enforced strict base. Pinned 0.2.93 + * documents the workspace base as read-everywhere. + */ +export function selectGrokCopresenceSandboxProfile( + profile: GrokCopresenceCapabilityProfile, + profiles: GrokCopresenceSandboxProfiles, +): string { + return profile === "repo-read" ? profiles.strictProfile : profiles.workspaceProfile; +} diff --git a/agent-node/src/runtime/grok-copresence/runtime.ts b/agent-node/src/runtime/grok-copresence/runtime.ts index 1b7dac1dd..9f4d9f7a9 100644 --- a/agent-node/src/runtime/grok-copresence/runtime.ts +++ b/agent-node/src/runtime/grok-copresence/runtime.ts @@ -2101,7 +2101,7 @@ class GrokCopresenceRuntime implements GrokCopresenceRuntimeSession { } } else if (isAutomaticApprovalLifecycleEvent(event)) { // Expected for the pinned `--always-approve` launch. Explicit deny - // rules and the fixed three-tool profile remain authoritative. + // rules and the exact runtime-owned profile remain authoritative. continue; } } diff --git a/docs/tests/report-grok-copresence-repo-read-stage2.txt b/docs/tests/report-grok-copresence-repo-read-stage2.txt new file mode 100644 index 000000000..01c4a1799 --- /dev/null +++ b/docs/tests/report-grok-copresence-repo-read-stage2.txt @@ -0,0 +1,124 @@ +# Grok TUI co-presence repo-read stage-2 evidence + +Date: 2026-08-13 (Asia/Shanghai) + +## Provenance + +source_commit=449683586a5a2ba44e99eb8c595be25d7467c967 +base_commit=8186b79de8e2f904c28bec268d93a523503a6845 +tree=21cd907323b374f2df6c722d314c05597ea0c093 +unit_image_id=sha256:d46c3dc573bba4e282750df6877fc893e490cc5d2fdfeab5c2de2e0c246dae53 +readiness_image_id=sha256:459b3239eae27afb6aa6a4cac7375f45c10c2df223083352187c92ca820cd60f +unit_image_tag=anet-grok-repo-read:44968358 +readiness_image_tag=anet-test813-readiness:44968358 + +Both images were built from `git archive` of the exact source commit with +`SOURCE_COMMIT` set to the same value. Seventeen source/fixture files present +in the two images were copied back out and compared with Git: `17/17 MATCH`. +No live node, package, production database, or production configuration was +changed. + +## Intended boundary + +The new profile is admitted only for the exact configured tool vector: + + ["Read", "Grep", "Glob"] + +It maps once at process startup to the `repo-read` capability and a generated +Grok custom strict sandbox. The model-visible tools are exactly: + + todo_write search_tool use_tool read_file grep list_dir + +There is no shell, file-write, web, media, or subagent tool in this profile. +Reordered, missing, additional, whitespace-mutated, or otherwise near-match +configuration vectors fail closed. Resume cannot widen an existing process; +enabling repo-read requires a fresh Grok session. + +The restack deliberately preserves the #813 command/readiness boundary. The +CommHub MCP executable is resolved to one canonical executable before runtime +home staging, staged unchanged into the strict profile's read-only set, and +must pass the four-tool doctor before TUI readiness. + +## Exact-source Docker verification + +Complete agent-node unit domain, non-root, Bun 1.3.14: + + 1284 pass + 0 fail + 4406 expect() calls + Ran 1284 tests across 91 files. [117.66s] + MUTATION_RED readable-attachment-runtime-disconnected rc=1 + RESULT: PASS + +Targeted repo-read verification in the same exact-source image: + + agent-node targeted: 47 pass / 0 fail / 364 expect + agent-node build: PASS + agent-network disclosure: 5 pass / 0 fail / 30 expect + agent-network tsc --noEmit: PASS + +The exact-source #813 readiness/product path also passed after the restack: + + MCP_READINESS_PASS tools=commhub_get_all_status,commhub_send_message,commhub_send_task,commhub_upload_file + MUTATION_RED upload-tool-removed + MUTATION_RED stale-three-tool-doctor + PRODUCT_PATH_NEGATIVE_PASS registration=absent session=unchanged + PRODUCT_PATH_RECOVERY_PASS session=preserved + MUTATION_RED doctor-three-tools-product-path-before-tui + MUTATION_RED bun-resolver-bypassed + PRODUCT_PATH_NEGATIVE_PASS registration=absent session=unchanged + PRODUCT_PATH_RECOVERY_PASS session=preserved + RESULT: PASS source_commit=449683586a5a2ba44e99eb8c595be25d7467c967 + +## Named witnessed-red mutation + +The selector mutation changes only the production repo-read result from the +strict profile to the workspace profile. Baseline runs first; the mutation +target and replacement are exact. The same exact-source image then emits: + + 2 pass + 1 fail + Expected: "anet-strict" + Received: "anet-workspace" + MUTATION_RED repo-read-selector-strict-to-workspace rc=1 + +This proves the test carries the strict-selection behavior rather than merely +observing a non-zero mutation exit. + +## Restack correction + +The superseded source `8929fc28` resolved a bare `bun` again inside MCP staging. +That overlapped the later #813 fix, which resolves and validates the executable +before staging and passes the absolute command through the runtime boundary. +This source keeps the #813 design and removes the older internal resolver while +retaining repo-read's strict sandbox and read-only dependency grants. The old +report is not evidence for this source. + +## Run log digests + + unit build f21e495e0841c1c52e931bc645bdafe0d3c0d1e043dfcd1781293db83714a527 + unit run 24621f6a97145424501a78cedadde4a9de6376d9e3527cd0cf68a092d3ee6d4c + targeted run 5c64460b8081fbb766e0eb2fd79d33891565e13e8e73dae81413e7ddb591ccf3 + selector mutant 7348b1a1aaf6e2b85d580eeaa3a59375abe2c74efbe714a55fe65e47023abe9d + readiness build 5b4468efd82d54f6b209c7a9d6d369f41eef41353d1df41821cfe72cc48abb8f + readiness run 535aedcce1ed1b521e80e7fa36c735591545c99294f94b983e842022d4fa7d22 + +The logs contain timing and Docker progress, so their digests are run records, +not byte-reproducible anchors. Git coordinates, image IDs, and `17/17 MATCH` +are the reproducible provenance. + +## Honest limits / rollout gate + +- NOT COVERED: the earlier real pinned-Grok sandbox observations belong to + source `8929fc28`; they are not claimed as evidence for this restacked source. +- NOT COVERED: a live Dashboard-origin task proving repository read plus a + CommHub reply in the same TUI session. +- NOT COVERED: independent adversarial review of this source/report pair. +- NOT COVERED: non-Linux and multi-architecture execution; Linux is the current + deployment target. +- The live `通信狗` remains on its validated `x-search` profile. It was not + restarted or upgraded by this work. +- No rollout is authorized by this report. A pilot still requires backup and + rollback coordinates, a fresh Grok session, observed `ProfileApplied` with + `enforced=true`, exact tool inventory, inside/outside/credential behavioral + probes, and a real CommHub round trip before deeper participation.