diff --git a/CHANGELOG.md b/CHANGELOG.md index baac8fbb..28741341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ product overview. ## Unreleased +- Supply each attempt's resolved admin permission bypass decision to the assistant, so it can + distinguish its actual launch permissions from the channel's Admin label and writable tools. + Permission policies and container boundaries are unchanged. + - Preserve existing unavailable Cloud MCP selections when saving unrelated admin settings. Removing a selection refreshes the channel picker and prevents later saves from restoring it. diff --git a/FEATURES.md b/FEATURES.md index f3808e91..f03cb1cd 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -913,6 +913,10 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. configured model/effort and fresh/resumed session state, including clean mode, model retries, session recovery and cross-engine fallback. The assistant is told to distinguish configured aliases from provider-reported identity and leave unexposed harness defaults unknown. + The access preamble also states the launch's resolved admin permission bypass decision, + including resumed, background and fallback attempts. It distinguishes this decision from + channel Admin mode, writable tools and Codex's internal sandbox/approval settings; callers + without a resolved decision report unknown. The restricted memory reviewer states disabled. → TEST-PLAN: Per-attempt runtime identity. - **Disposable Linux lifecycle evidence:** the manually dispatchable Linux lifecycle workflow diff --git a/README.md b/README.md index e7d1a8fb..820ae6b1 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ catalog, including edge cases and links to regression coverage. removes the container boundary for that thread, so use it only for trusted host operations. - **Read-only, Worker and Admin modes:** choose the base permission level. Auto review and Lean context are separate options; permission bypass requires both an admin author and Admin mode. + Each attempt tells the assistant whether its actual launch has admin permission bypass; + the channel's Admin label and writable container tools do not establish that permission. - **Approval controls:** interactive approvals and automatic review apply according to mode; tool permissions remain separate from the container's filesystem boundary. - **User and guest access:** select approved-member access, admins only or locked access, diff --git a/TEST-PLAN.md b/TEST-PLAN.md index e43d9bdc..28adb05c 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -4065,6 +4065,23 @@ pass. Many checks are manual (require a real Slack workspace + an authenticated ## Per-attempt runtime identity +- [x] Resolved permission facts: `test/runtime-identity-preamble.test.js` compares delivered + preambles with actual fixture CLI bypass flags for Claude and Codex members/admins in Admin + mode, resumed turns, an admin background agent, a Clean member, guarded home mounts and + cross-engine fallback. Existing model retry/healing cases check the same agreement. + `test/runtime-access-facts.test.js` keeps an absent decision unknown regardless of channel + flags or mounted home; `test/memory-review.test.js` requires the reviewer's explicit false + decision in both its launch options and prompt. These tests prove delivery, not live answers. +- [ ] Live permission-report retest, Claude and Codex separately (existing Slack Access runtime + acceptance): reserve an owned Admin-mode fixture with Auto/Lean/network off, preserve the + operator's whole-home setting, and use an approved member plus an organization admin. Ask + each to create a distinct proof file containing `access proof` and report whether permission + bypass is active in this attempt. Require member **disabled** and admin **enabled** in the + answers, matching exact-root `run_config.dangerouslySkip`, delivered per-attempt facts and + actual launch flags. A writable container or Codex `danger-full-access`/`approval_policy=never` + does not establish admin bypass. Repeat on a resumed member turn; retain the original failed + answer and append retest evidence. Keep all existing Access native-save, admission, Lean, + network, selector and restoration gates; remove proof files and restore owned settings. - [x] `test/runtime-identity-preamble.test.js` inspects actual stub process argv through the container-shaped fake runtime: fresh/resumed Claude and Codex turns with a changed thread model, clean runs with unexposed defaults, rejected-model replacement, cross-engine fallback diff --git a/src/gateway/memory-review.js b/src/gateway/memory-review.js index 1f40f5d8..964cfffd 100644 --- a/src/gateway/memory-review.js +++ b/src/gateway/memory-review.js @@ -34,6 +34,7 @@ import { recordUsage } from "./usage.js"; import { logEvent } from "../util/logger.js"; import { postNotice } from "../platforms/notify.js"; import { allowedFsRoot } from "../web/security.js"; +import { permissionBypassNote } from "./runtime-access.js"; export const REVIEW_ORIGIN = "memory_review"; export const REVIEW_TOOLSET = "memory-review"; @@ -148,7 +149,9 @@ export async function runMemoryReview({ const snapshot = await readMemorySnapshot(cwd, meta); const transcript = String((await fetchTranscript?.()) || "").trim(); if (!transcript) return { skipped: "no-transcript", saved: 0 }; - const prompt = buildMemoryReviewPrompt({ snapshot, transcript, channelName: meta.name || slug }); + const dangerouslySkip = false; // The reduced-tool reviewer never receives the admin bypass. + const prompt = permissionBypassNote(dangerouslySkip) + + buildMemoryReviewPrompt({ snapshot, transcript, channelName: meta.name || slug }); const model = getMemoryReviewModel(); const target = resolveTarget(slug, meta); @@ -205,6 +208,7 @@ export async function runMemoryReview({ const result = await run({ cwd, prompt, + dangerouslySkip, sessionId: randomUUID(), isNewSession: true, mcpConfig: mcpConfigFile, diff --git a/src/gateway/run.js b/src/gateway/run.js index c82f48e5..55e65860 100644 --- a/src/gateway/run.js +++ b/src/gateway/run.js @@ -1445,7 +1445,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, // them per-prompt even in clean mode: they expose no memory, optional skills or connectors. const prompt = (fresh && !forkSourceSessionId ? memoryPrefix : "") + composioIdentityPrefix + channelCredentialsPrefix + runtimeIdentityPreamble({ engine, model: modelOverride, effort, fresh }) - + runtimeAccessPreamble(target, { clean, allowNetwork: Boolean(meta.allowNetwork) }) + + runtimeAccessPreamble(target, { clean, allowNetwork: Boolean(meta.allowNetwork), dangerouslySkip }) + perplexityResearchPreamble(target, { clean }) + (promptOverride ?? turnText); return adapter.run(validateRunContext({ @@ -1686,7 +1686,7 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text, principal: { kind: untrustedPrincipal ? "daemon" : PRINCIPAL_KIND_BY_ORIGIN[origin] || "daemon", id: authorId }, origin, cwd, prompt: (fbFresh ? memoryPrefix : "") + composioIdentityPrefix + channelCredentialsPrefix + runtimeIdentityPreamble({ engine: fallbackEngine, model: modelOverride, effort: "", fresh: fbFresh }) - + runtimeAccessPreamble(target, { clean, allowNetwork: Boolean(meta.allowNetwork) }) + perplexityResearchPreamble(target, { clean }) + fbPrompt, + + runtimeAccessPreamble(target, { clean, allowNetwork: Boolean(meta.allowNetwork), dangerouslySkip }) + perplexityResearchPreamble(target, { clean }) + fbPrompt, session: { id: fbSid, fresh: fbFresh }, policy: fallbackConfinement, // The SAME runtime target: failing over to the other harness changes which CLI runs, not // which machine boundary the channel runs behind. diff --git a/src/gateway/runtime-access.js b/src/gateway/runtime-access.js index b3163b46..fe2f783b 100644 --- a/src/gateway/runtime-access.js +++ b/src/gateway/runtime-access.js @@ -82,10 +82,23 @@ export function containerAccessNote(target) { return `**Container access for this run:** gateway setting \`containerFullAccessHome\` is **${setting}**. ${access} The operator-home mount is available ONLY to a trusted admin author's run in an Admin/Full-access channel while that gateway switch is on; with the switch off, Admin adds no home mount. The container remains the filesystem/process boundary. \`$HOME\` and \`~\` still refer to the channel's own home volume, not the operator's home. See the \`gateway-usage\` skill's \`references/administration.md\` for the boundary and the optional grant.`; } -export function runtimeAccessPreamble(target, { clean, allowNetwork } = {}) { +// The launcher supplies its resolved decision. Channel mode, writable tools and mounted home +// cannot establish the current author's bypass, and a caller that omits it must stay unknown. +export function permissionBypassNote(dangerouslySkip) { + const state = typeof dangerouslySkip === "boolean" ? (dangerouslySkip ? "enabled" : "disabled") : "unknown"; + return `Admin permission bypass for THIS attempt: **${state}**. ` + + (state === "unknown" + ? "The resolved launch decision was not supplied; do not infer it from the channel's Admin mode. " + : "This is the gateway's resolved admin-only bypass decision for this launch; use it when asked whether permission bypass is active. ") + + "Worker file writes, a writable container, Codex danger-full-access and approval_policy=never do not establish gateway admin bypass. " + + "Permission bypass and filesystem mount reach are separate facts.\n"; +} + +export function runtimeAccessPreamble(target, { clean, allowNetwork, dangerouslySkip } = {}) { return "[Gateway container access for THIS attempt]\n" + "These current access facts supersede earlier turns and generic claims about host isolation. " + "Answer access questions from this resolved runtime, even when the conversation previously said otherwise.\n" + + permissionBypassNote(dangerouslySkip) + containerAccessNote(target) + "\n" + gatewayStoreAccessNote(target) + "\n" + "Respect the user's requested scope when checking access. For requests limited to existence, metadata or permission checks, use resolved mount facts and non-mutating metadata checks only. Do not read file contents or create, modify, or delete probe files, even temporarily. If metadata cannot establish write access, report it as unverified; do not upgrade an access-check request into a write test.\n" diff --git a/test/memory-review.test.js b/test/memory-review.test.js index a832a209..8070b47b 100644 --- a/test/memory-review.test.js +++ b/test/memory-review.test.js @@ -154,7 +154,9 @@ test("a review marks the channel live WITHOUT an owner, and releases it after", await review.runMemoryReview({ channelId: "C_MEM_REVIEW_LIVE", slug: entry.slug, threadKey: "7000.009", authorId: "U_MEM_REVIEW", meta, resolveTarget, fetchTranscript: async () => "Alex: hi\nRobin: hello", - run: async () => { + run: async (options) => { + assert.equal(options.dangerouslySkip, false); + assert.match(options.prompt, /Admin permission bypass for THIS attempt: \*\*disabled\*\*/); during = liveSnapshot().filter((e) => e.channelId === "C_MEM_REVIEW_LIVE"); return { content: "NOTHING_TO_SAVE", durationMs: 1, costUSD: 0, usage: {} }; }, diff --git a/test/runtime-access-facts.test.js b/test/runtime-access-facts.test.js index f215643b..a866343d 100644 --- a/test/runtime-access-facts.test.js +++ b/test/runtime-access-facts.test.js @@ -51,9 +51,21 @@ test("missing resolved facts stay unknown instead of inferring a mode or host is assert.match(note, /database visibility are unknown/); assert.match(note, /Clean mode for this attempt is unknown/); assert.match(note, /Network policy for this attempt is unknown/); + assert.match(note, /Admin permission bypass for THIS attempt: \*\*unknown\*\*/); assert.doesNotMatch(note, /runtime directory: \*\*(not )?mounted\*\*/); }); +test("permission facts use the supplied launch decision, never channel mode or mounted home", () => { + const home = target([bind(path.dirname(gatewayRoot()), undefined, "operator-home")]); + home.meta = { adminMode: true, autoMode: true }; + for (const dangerouslySkip of [false, true]) { + const note = runtimeAccessPreamble(home, { dangerouslySkip }); + assert.match(note, new RegExp(`Admin permission bypass for THIS attempt: \\*\\*${dangerouslySkip ? "enabled" : "disabled"}\\*\\*`)); + assert.match(note, /danger-full-access.*approval_policy.*never.*do not establish.*admin bypass/); + } + assert.match(runtimeAccessPreamble(home), /Admin permission bypass for THIS attempt: \*\*unknown\*\*/); +}); + test("a sudo-host target states that the container boundary is absent", () => { const note = runtimeAccessPreamble({ backend: "host", container: null }, { clean: false, allowNetwork: true }); assert.match(note, /Direct host access.*enabled by `\/sudo`/s); diff --git a/test/runtime-identity-preamble.test.js b/test/runtime-identity-preamble.test.js index aa21e0dc..148da8d4 100644 --- a/test/runtime-identity-preamble.test.js +++ b/test/runtime-identity-preamble.test.js @@ -59,9 +59,71 @@ function metadata(call) { assert.match(prompt, /For requests limited to existence, metadata or permission checks, use resolved mount facts and non-mutating metadata checks only/); assert.match(prompt, /Do not read file contents or create, modify, or delete probe files, even temporarily/); assert.match(prompt, /If metadata cannot establish write access, report it as unverified/); + assertBypassMatchesLaunch(call); return JSON.parse(match[1]); } +function assertBypassMatchesLaunch(call) { + const prompt = call.args.find((arg) => String(arg).includes("[Gateway runtime for THIS attempt:")); + const bypass = call.args.some((arg) => ["--dangerously-skip-permissions", "--dangerously-bypass-approvals-and-sandbox"].includes(arg)); + assert.match(prompt, new RegExp(`Admin permission bypass for THIS attempt: \\*\\*${bypass ? "enabled" : "disabled"}\\*\\*`)); + return bypass; +} + +for (const engine of ["claude", "codex"]) { + test(`${engine}: a guarded home mount does not make a member's bypass fact true`, async (t) => { + const context = await fixture(`PERMISSION_GUARD_${engine}`, engine, engine === "claude" ? "sonnet" : "gpt-5.6-sol", { adminMode: true }); + const previousPrepare = backend.prepareTarget; + backend.prepareTarget = (base) => { + const target = previousPrepare(base); + // Exercise the retained home guard for a widened target; normal member targets have no + // operator-home mount. This fake backend executes only fixture CLIs, never a container. + target.container.mounts = [...target.container.mounts, { type: "bind", kind: "operator-home", source: os.homedir(), target: os.homedir() }]; + return target; + }; + t.after(() => { backend.prepareTarget = previousPrepare; }); + const start = backend.calls.spawn.length; + await runMessage({ ...context, text: "Does the guarded mount grant admin bypass?" }); + const [call] = attempts(start); + assert.equal(assertBypassMatchesLaunch(call), false); + if (engine === "codex") assert.equal(call.args[call.args.indexOf("--sandbox") + 1], "read-only"); + }); + + test(`${engine}: permission facts follow actual author and origin across fresh, resumed and Clean attempts`, async () => { + const context = await fixture(`PERMISSION_FACTS_${engine}`, engine, engine === "claude" ? "sonnet" : "gpt-5.6-sol", { adminMode: true, autoMode: false }); + saveSettings({ containerFullAccessHome: true }); + const start = backend.calls.spawn.length; + await runMessage({ ...context, text: "Does Admin mode give this member permission bypass?" }); + await runMessage({ ...context, text: "Does a successful write change the bypass fact?" }); + await setUser(context.authorId, { name: "Permission admin", approved: true, isAdmin: true }); + await runMessage({ ...context, text: "Report the actual admin bypass for this attempt." }); + await runMessage({ ...context, origin: "background_agent", text: "Report the actual bypass for this unattended attempt." }); + await setUser(context.authorId, { name: "Permission member", approved: true, isAdmin: false }); + await patchChannelMeta(context.entry.slug, { cleanMode: true }); + await runMessage({ ...context, text: "Report bypass in this member's Clean attempt." }); + const calls = attempts(start); + assert.equal(calls.length, 5); + assert.deepEqual(calls.map(assertBypassMatchesLaunch), [false, false, true, false, false]); + assert.equal(metadata(calls[1]).session, "resumed"); + const memberPrompt = calls[0].args.find((arg) => String(arg).includes("[Gateway runtime for THIS attempt:")); + assert.match(memberPrompt, /resolved runtime has no operator-home mount/); + const adminPrompt = calls[2].args.find((arg) => String(arg).includes("[Gateway runtime for THIS attempt:")); + assert.match(adminPrompt, /resolved runtime includes the operator-home mount/); + }); +} + +test("admin bypass facts reach the actual fallback engine as well as the primary", async () => { + const context = await fixture("PERMISSION_FALLBACK", "claude", "sonnet", { adminMode: true }); + await setUser(context.authorId, { name: "Fallback admin", approved: true, isAdmin: true }); + const start = backend.calls.spawn.length; + await runMessage({ ...context, text: "CLAUDE_STUB_LIMIT_FAIL_SAFE" }); + const calls = attempts(start); + assert.equal(calls.length, 2); + assert.deepEqual(calls.map(assertBypassMatchesLaunch), [true, true]); + assert.equal(metadata(calls[0]).engine, "claude"); + assert.equal(metadata(calls[1]).engine, "codex"); +}); + for (const engine of ["claude", "codex"]) { test(`${engine}: network policy changes reach fresh and resumed prompts without exposing Clean credentials`, async () => { const context = await fixture(`NETWORK_FACTS_${engine}`, engine, engine === "claude" ? "sonnet" : "gpt-5.6-sol", { allowNetwork: true });