From 625fa75817aa0f4bca00d63b5c330889fdbefad4 Mon Sep 17 00:00:00 2001 From: Moss Grok Date: Sun, 11 Oct 2026 14:38:45 +0800 Subject: [PATCH 1/2] draft(o72): approval mode line, accept-edits help, headless block copy --- src/cli-main.ts | 6 +- src/cli/approval.ts | 13 +- src/cli/cli-locale.ts | 65 +++++++--- src/cli/help.ts | 4 +- test/cli-permission-defaults.spec.mjs | 23 ++-- test/cli-project-instructions.spec.mjs | 4 +- test/cli-ui-language.spec.mjs | 3 +- test/first-run.spec.mjs | 4 +- test/o72-approval-mode-copy.spec.mjs | 159 +++++++++++++++++++++++++ test/workspace-write-copy.spec.mjs | 8 +- 10 files changed, 238 insertions(+), 51 deletions(-) create mode 100644 test/o72-approval-mode-copy.spec.mjs diff --git a/src/cli-main.ts b/src/cli-main.ts index 19915ede..d7e9256f 100644 --- a/src/cli-main.ts +++ b/src/cli-main.ts @@ -630,10 +630,10 @@ async function main() { // over unless a flag already set it above. if (parsedArgs.interactionModeOverride || startupMode !== 'manual') { setCliInteractionMode(startupMode); - if (cliDetailForNotices !== 'quiet') { - console.error(formatInteractionModeNotice(parsedArgs.interactionModeOverride ?? startupMode)); - } } + // O72: the resolved mode is printed on every start (stderr), including -p + // and --quiet, so a user can always tell whether Moss will ask first. + console.error(formatInteractionModeNotice(parsedArgs.interactionModeOverride ?? startupMode)); // Once per config dir. Doctor still reports the condition on every run. if (cliDetailForNotices !== 'quiet' && shouldShowFullDefaultNotice(resolvedConfig)) { printWrappedNotice(formatFullModeNotice()); diff --git a/src/cli/approval.ts b/src/cli/approval.ts index fd2089f8..fe5c199c 100644 --- a/src/cli/approval.ts +++ b/src/cli/approval.ts @@ -20,7 +20,7 @@ import { assertSandboxPath } from '../safety/sandbox-paths.js'; import { sanitizeSecrets } from '../safety/secret-sanitizer.js'; import { recordDevicePolicyDecision } from './device-policy-log.js'; import { deviceDestructivePrompt } from './device-safety-prompt.js'; -import { isZhLocale } from './cli-locale.js'; +import { formatHeadlessBlockNotice, formatHeadlessBlockReason, isZhLocale } from './cli-locale.js'; import { normalizeSafetyModeConfig, loadConfigFile, @@ -1263,7 +1263,9 @@ export function createCliToolApprovalHook( console.error( deviceGatedAsk && isZhLocale() ? `[moss] 需要确认,但当前没有交互终端:${tool.name}` - : `[moss] Approval required but no interactive terminal is available: ${tool.name}` + : deviceGatedAsk + ? `[moss] Approval required but no interactive terminal is available: ${tool.name}` + : formatHeadlessBlockNotice(tool.name, effectiveMode) ); headlessNoticeShown = true; } @@ -1271,12 +1273,7 @@ export function createCliToolApprovalHook( approved: false, reason: destructiveCopy ? destructiveCopy.headlessReason - : `Tool "${tool.name}" requires approval, but Moss is running non-interactively. ` + - 'To let it run: switch the mode to full — `/mode full` in an interactive session, ' + - '`--full-access` (this process only), or `moss config set permissions.defaultMode=full` ' + - '(persistent); add narrow allow rules with /permissions for single tools. ' + - 'This gate is inherited by sub-agents: delegating the same call via ' + - 'create_subagent or fan_out_subagents will be denied too, so do not retry it that way.', + : formatHeadlessBlockReason(tool.name, effectiveMode), }); } diff --git a/src/cli/cli-locale.ts b/src/cli/cli-locale.ts index e1a1fd43..6c6280da 100644 --- a/src/cli/cli-locale.ts +++ b/src/cli/cli-locale.ts @@ -333,16 +333,17 @@ export function formatInteractionModeNotice(mode: string, locale?: string): stri const zh = isZhLocale(locale); const labels: Record = zh ? { - plan: 'plan(只读演练)', - acceptEdits: 'accept-edits(自动接受编辑)', - manual: 'manual(逐项确认)', - full: 'full(默认 — 用 /permissions 添加拒绝规则)', + plan: 'plan(只读演练,不改文件、不跑命令)· /mode 可切换', + acceptEdits: 'accept-edits(自动通过本文件夹内的文件编辑;终端命令仍需确认)· /mode 可切换', + manual: 'manual(逐条确认)· /mode 可切换', + full: 'full(完全执行,不逐条询问)· /mode 可切换', } : { - plan: 'plan (dry-run)', - acceptEdits: 'accept-edits', - manual: 'manual', - full: 'full (v0.26 default — add deny rules with /permissions)', + plan: 'plan (dry-run: no edits, no commands) · /mode to change', + acceptEdits: + 'accept-edits (file edits in this folder auto-approve; shell commands still ask) · /mode to change', + manual: 'manual (asks before each action) · /mode to change', + full: 'full (no approval prompts) · /mode to change', }; const label = labels[mode] ?? mode; return zh ? `[moss] 交互模式:${label}` : `[moss] Interaction mode: ${label}`; @@ -353,15 +354,51 @@ export function wrapNoticeLines(text: string, columns = 80): string[] { return wrap(text, Math.max(20, columns)); } -/** One-shot notice when full mode has no deny rules. */ +/** One-shot notice when full mode has no deny rules (copy-audit 1.9). */ export function formatFullModeNotice(locale?: string): string { const zh = isZhLocale(locale); - const limit = setupCopy(locale, WORKSPACE_WRITE_LIMIT_EN); return zh - ? `[moss] 默认完全访问模式没有拒绝规则;用 /permissions 添加(例如 \`deny read_file(./.env)\`)以继续拦截敏感工具。${limit}此提示只显示一次。` - : '[moss] Default full mode has no deny rules; add them with /permissions ' + - '(e.g. deny read_file(./.env)) to keep sensitive tools gated. ' + - `${limit} This notice shows once.`; + ? '[moss] 完全执行模式:Moss 会直接改文件、跑命令,不逐条询问。想逐条确认:/mode manual(或启动时加 --workspace-write)。要拦截特定操作:/permissions。此提示只显示一次。' + : '[moss] Full mode: Moss edits files and runs commands without asking. ' + + 'To approve each action: /mode manual (or start with --workspace-write). ' + + 'Block specific things with /permissions. This notice shows once.'; +} + +/** + * O72: stderr line shown to the USER when a non-interactive run (-p / no TTY) + * blocks a tool that needs approval. Names the mode that caused it and how to + * allow it. The model-facing reason is {@link formatHeadlessBlockReason}. + */ +export function formatHeadlessBlockNotice(toolName: string, mode: string, locale?: string): string { + const zh = isZhLocale(locale); + if (mode === 'acceptEdits') { + return zh + ? `[moss] 已拦下:「${toolName}」需要确认,但 -p 模式无法询问。本次用了 --accept-edits(只自动通过文件编辑)。去掉 --accept-edits 或改用 --full-access 重新运行,或用 /permissions 放行。` + : `[moss] Blocked: "${toolName}" needs approval, but -p can't ask. You started with --accept-edits (auto-approves file edits only). Rerun without --accept-edits or with --full-access, or allow it with /permissions.`; + } + return zh + ? `[moss] 已拦下:「${toolName}」需要确认,但当前没有交互终端(模式:${mode})。改用 --full-access 重新运行,或用 /permissions 放行。` + : `[moss] Blocked: "${toolName}" needs approval, but no interactive terminal is available (mode: ${mode}). Rerun with --full-access, or allow it with /permissions.`; +} + +/** + * O72: reason returned to the MODEL for a non-interactive approval block. + * States the cause only. It must not coach the model to tell the user to + * switch to full mode (the user already got the unlock line on stderr). + */ +export function formatHeadlessBlockReason(toolName: string, mode: string): string { + const cause = + mode === 'acceptEdits' + ? 'This run uses accept-edits: file edits in the workspace auto-approve, other tools still need approval.' + : `The current mode (${mode}) needs approval for this tool.`; + return ( + `Tool "${toolName}" requires approval, but Moss is running non-interactively and cannot ask. ` + + `${cause} The user has already been shown how to allow it. ` + + 'Do not ask the user to change the approval mode; mention once that this step was blocked and ' + + 'continue with tools that are allowed. ' + + 'This gate is inherited by sub-agents: delegating the same call via ' + + 'create_subagent or fan_out_subagents will be denied too, so do not retry it that way.' + ); } const SOURCE_ZH: Record = { diff --git a/src/cli/help.ts b/src/cli/help.ts index 7bb6a34f..a4d2da50 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -117,7 +117,7 @@ export function fullHelpLines(c: Colors, configPath: string, zh = false): string ` ${c.yellow('-C, --cd')} use a different workspace`, ` ${c.yellow('--read-only')} · ${c.yellow('--workspace-write')} · ${c.yellow('--full-access')} mode overrides: manual+ceiling / manual / full (deny rules and hard blocks still apply). ${workspaceWriteLimit(false)}`, ` ${c.yellow('--trust-device')} destructive device ops this process · ${c.yellow('--trust-workspace')} trust this folder (hooks, MCP, routing)`, - ` ${c.yellow('--accept-edits')} · ${c.yellow('--plan')} · ${c.yellow('--ask-for-approval')} other mode overrides (mutually exclusive)`, + ` ${c.yellow('--accept-edits')} · ${c.yellow('--plan')} · ${c.yellow('--ask-for-approval')} other mode overrides (mutually exclusive). --accept-edits: auto-approve file edits in this folder; shell commands (incl. mkdir/mv/rm) still ask, narrower than Claude Code's acceptEdits; in -p they are blocked (use --full-access to allow)`, ` ${c.yellow('--mock')} · ${c.yellow('--json')} · ${c.yellow('--output-format')} offline · machine-readable output`, ` ${c.yellow('--quiet')} · ${c.yellow('--verbose')} · ${c.yellow('--debug')} · ${c.yellow('--no-color')}`, ` ${c.bold('Environment')}`, @@ -166,7 +166,7 @@ function fullHelpLinesZh(c: Colors, configPath: string, interactiveLines: string ` ${c.yellow('-C, --cd')} 换一个工作区`, ` ${c.yellow('--read-only')} · ${c.yellow('--workspace-write')} · ${c.yellow('--full-access')} 模式覆盖:手动+上限 / 手动 / 完全访问(拒绝规则和硬拦截仍然生效)。${workspaceWriteLimit(true)}`, ` ${c.yellow('--trust-device')} 本进程允许毁灭性设备操作 · ${c.yellow('--trust-workspace')} 信任此文件夹(钩子、MCP、路由)`, - ` ${c.yellow('--accept-edits')} · ${c.yellow('--plan')} · ${c.yellow('--ask-for-approval')} 其它互斥的模式覆盖`, + ` ${c.yellow('--accept-edits')} · ${c.yellow('--plan')} · ${c.yellow('--ask-for-approval')} 其它互斥的模式覆盖。--accept-edits:自动通过本文件夹内的文件编辑;终端命令(包括 mkdir/mv/rm)仍需确认,比 Claude Code 的 acceptEdits 窄;-p 下没人确认会被拦下(需要时用 --full-access)`, ` ${c.yellow('--mock')} · ${c.yellow('--json')} · ${c.yellow('--output-format')} 离线 · 机器可读输出`, ` ${c.yellow('--quiet')} · ${c.yellow('--verbose')} · ${c.yellow('--debug')} · ${c.yellow('--no-color')}`, ` ${c.bold('环境变量')}`, diff --git a/test/cli-permission-defaults.spec.mjs b/test/cli-permission-defaults.spec.mjs index 4aba6378..10ec0415 100644 --- a/test/cli-permission-defaults.spec.mjs +++ b/test/cli-permission-defaults.spec.mjs @@ -494,21 +494,14 @@ const tool = (name, sideEffectClass) => ({ }); Object.defineProperty(process.stdin, 'isTTY', { value: wasTTY, configurable: true }); assert.equal(decision.approved, false, 'headless stays denied without a policy'); - // v0.26 (T03, PRD W3): the headless rejection names the NEW mode knobs — - // /mode full, --full-access, permissions.defaultMode — not the old - // --accept-edits/profile=autonomous/MOSS_CLI_AUTO_APPROVE trio. - assert.match(decision.reason, /\/mode full/, 'the rejection names /mode full'); - assert.match(decision.reason, /--full-access/, 'the rejection names --full-access'); - assert.match( - decision.reason, - /permissions.defaultMode=full/, - 'the rejection names the persistent permissions.defaultMode key' - ); - assert.match( - decision.reason, - /\/permissions/, - 'the rejection points at /permissions allow rules' - ); + // O72 (roadmap §17): the model-facing rejection states the cause only and + // must not coach the model to push the user to full mode. The unlock knobs + // (--full-access, /permissions) go to the user on stderr instead + // (formatHeadlessBlockNotice; covered in o72-approval-mode-copy.spec.mjs). + assert.match(decision.reason, /requires approval/, 'the rejection states the cause'); + assert.doesNotMatch(decision.reason, /\/mode full/, 'no /mode full coaching'); + assert.doesNotMatch(decision.reason, /--full-access/, 'no --full-access coaching'); + assert.doesNotMatch(decision.reason, /defaultMode=full/, 'no persistent-full coaching'); } // ─── 10. 'a' trust semantics (unchanged in T01; T03 moves to rules) ───────── diff --git a/test/cli-project-instructions.spec.mjs b/test/cli-project-instructions.spec.mjs index 7fe04ad4..cd156d9b 100644 --- a/test/cli-project-instructions.spec.mjs +++ b/test/cli-project-instructions.spec.mjs @@ -89,6 +89,6 @@ console.log('[PASS] project-instructions (AGENTS.md layer)'); assert.match(quick, /\[Answer language\]/); assert.match(formatInteractionModeNotice('full', 'zh_CN.UTF-8'), /交互模式/); assert.match(formatInteractionModeNotice('full', 'en_US.UTF-8'), /Interaction mode/); - assert.match(formatFullModeNotice('zh_CN.UTF-8'), /拒绝规则/); - assert.match(formatFullModeNotice('en_US.UTF-8'), /deny rules/); + assert.match(formatFullModeNotice('zh_CN.UTF-8'), /完全执行模式/); + assert.match(formatFullModeNotice('en_US.UTF-8'), /Full mode/); } diff --git a/test/cli-ui-language.spec.mjs b/test/cli-ui-language.spec.mjs index 8ade9752..bec24490 100644 --- a/test/cli-ui-language.spec.mjs +++ b/test/cli-ui-language.spec.mjs @@ -668,7 +668,8 @@ all-fail devices description view history failures verify false` for (const command of KNOWN_COMMANDS) LITERAL_TOKENS.add(command); const LANGUAGE_SETTINGS = new Set(['auto', 'en', 'zh']); -const EXACT_PHRASES = ['api key', 'node.js', 'task os']; +// Product names stay in English in zh copy (O72 help compares with Claude Code's acceptEdits). +const EXACT_PHRASES = ['api key', 'node.js', 'task os', 'claude code', 'acceptedits']; const MUTATION_SENTENCE = 'No model provider set, run setup or add a key.'; function stripAnsi(text) { diff --git a/test/first-run.spec.mjs b/test/first-run.spec.mjs index 0c249b92..9cde83b6 100644 --- a/test/first-run.spec.mjs +++ b/test/first-run.spec.mjs @@ -545,7 +545,7 @@ function tempDir(prefix) { assert.match(formatFullModeNotice('zh_CN'), /只显示一次/); assert.match(formatFullModeNotice('C'), /This notice shows once/); assert.match(formatInteractionModeNotice('full', 'zh_CN'), /交互模式/); - assert.match(formatInteractionModeNotice('full', 'C'), /full \(v0\.26 default/); + assert.match(formatInteractionModeNotice('full', 'C'), /full \(no approval prompts\)/); } // ─── "shown once" is persisted in the config dir ─────────────────────────── @@ -693,7 +693,7 @@ const cliCases = [ args: [], runs: 2, noticeFile: true, - firstMatch: [/This\s+notice shows once/, /Interaction mode: full \(v0\.26 default/], + firstMatch: [/This\s+notice shows once/, /Interaction mode: full \(no approval prompts\)/], eachMatch: [/Interaction mode:/], laterAbsent: [/This\s+notice shows once/], }, diff --git a/test/o72-approval-mode-copy.spec.mjs b/test/o72-approval-mode-copy.spec.mjs new file mode 100644 index 00000000..2616831d --- /dev/null +++ b/test/o72-approval-mode-copy.spec.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/** + * O72 (+O75): approval-mode copy and the non-interactive block. + * Acceptance: docs/plan roadmap additions §14 / §17. + * - default resolves to full; --accept-edits resolves to acceptEdits (manual + * base, prompt) — safety is NOT loosened. + * - startup mode line exists for every mode and says how to change it. + * - --help documents accept-edits scope vs Claude Code (en + zh). + * - -p + --accept-edits blocks exec; the model-facing reason names the cause + * and never coaches the model to push the user to full mode. + */ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createCliToolApprovalHook } from '../dist/cli/approval.js'; +import { parseCliArgs } from '../dist/cli/args.js'; +import { deriveEngineQuantas } from '../dist/cli/interaction-mode.js'; +import { + formatFullModeNotice, + formatHeadlessBlockNotice, + formatHeadlessBlockReason, + formatInteractionModeNotice, +} from '../dist/cli/cli-locale.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// 1. resolution (was /tmp/o75.mjs): --accept-edits → acceptEdits; quantas unchanged. +{ + const parsed = parseCliArgs(['-p', 'hi', '--accept-edits'], {}); + assert.equal(parsed.interactionModeOverride, 'acceptEdits'); + const none = parseCliArgs(['-p', 'hi'], {}); + assert.equal(none.interactionModeOverride ?? null, null, 'no flag → config default (full)'); + assert.deepEqual( + { + s: deriveEngineQuantas('acceptEdits').safetyMode, + a: deriveEngineQuantas('acceptEdits').approvalPolicy, + }, + { s: 'workspace-write', a: 'prompt' }, + 'accept-edits keeps the manual base (src/safety not loosened)' + ); + assert.equal(deriveEngineQuantas('full').approvalPolicy, 'never'); +} + +// 2. startup line: every mode, both locales, says how to change it. +for (const mode of ['full', 'manual', 'acceptEdits', 'plan']) { + assert.match( + formatInteractionModeNotice(mode, 'C'), + /^\[moss\] Interaction mode: .*\/mode to change/ + ); + assert.match( + formatInteractionModeNotice(mode, 'zh_CN.UTF-8'), + /^\[moss\] 交互模式:.*\/mode 可切换/ + ); +} +assert.match(formatInteractionModeNotice('full', 'C'), /no approval prompts/); +assert.doesNotMatch(formatInteractionModeNotice('full', 'C'), /v0\.26/); +assert.match(formatInteractionModeNotice('acceptEdits', 'C'), /shell commands still ask/); +assert.match(formatFullModeNotice('C'), /edits files and runs commands without asking/); +assert.match(formatFullModeNotice('C'), /\/mode manual/); +assert.match(formatFullModeNotice('zh_CN'), /不逐条询问/); + +// 3. user-facing block line names the flag and the way out. +assert.match( + formatHeadlessBlockNotice('exec', 'acceptEdits', 'C'), + /--accept-edits.*--full-access/ +); +assert.match(formatHeadlessBlockNotice('exec', 'acceptEdits', 'zh_CN'), /--accept-edits/); + +// 4. model-facing reason: cause yes, "switch to full" coaching no. +const FULL_COACHING = [ + /\/mode full/i, + /--full-access/, + /defaultMode\s*=\s*full/i, + /switch (the mode )?to full/i, + /切换到\s*full/, + /完全执行/, +]; +for (const mode of ['acceptEdits', 'manual']) { + const reason = formatHeadlessBlockReason('exec', mode); + for (const re of FULL_COACHING) + assert.doesNotMatch(reason, re, `reason (${mode}) must not match ${re}`); + assert.match(reason, /requires approval/); + assert.match(reason, /Do not ask the user to change the approval mode/); +} +assert.match(formatHeadlessBlockReason('exec', 'acceptEdits'), /accept-edits/); + +// 5. real hook, no TTY, accept-edits: exec blocked; edit allowed; reason clean. +{ + const tool = (name, sideEffectClass) => ({ + name, + description: name, + inputSchema: { type: 'object', properties: {} }, + metadata: { sideEffectClass, planMode: 'requires_user_confirmation' }, + execute: async () => 'ok', + }); + const savedTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + const errs = []; + const origErr = console.error; + console.error = (...a) => errs.push(a.join(' ')); + try { + const hook = createCliToolApprovalHook( + 'workspace-write', + {}, + { + workspaceDir: process.cwd(), + interactionMode: () => 'acceptEdits', + } + ); + const exec = await hook({ + tool: tool('exec', 'local_write'), + input: { command: 'npm install surprise-package' }, + sessionKey: 'o72', + }); + assert.equal(exec.approved, false, '-p + --accept-edits still blocks exec'); + for (const re of FULL_COACHING) + assert.doesNotMatch(exec.reason, re, `hook reason must not match ${re}`); + assert.ok( + errs.some((l) => /Blocked: "exec".*--accept-edits/.test(l)), + `stderr notice: ${errs.join('|')}` + ); + const edit = await hook({ + tool: tool('edit_file', 'local_write'), + input: { path: 'notes.txt', old_string: 'a', new_string: 'b' }, + sessionKey: 'o72', + }); + assert.equal(edit.approved, true, 'accept-edits auto-approves workspace file edits'); + } finally { + console.error = origErr; + Object.defineProperty(process.stdin, 'isTTY', { value: savedTTY, configurable: true }); + } +} + +// 6. help documents scope vs Claude Code, en + zh. +{ + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'o72-help-')); + const run = (lang) => + spawnSync(process.execPath, [path.join(root, 'dist/cli.js'), '--help', '--all'], { + env: { + PATH: process.env.PATH, + HOME: home, + XDG_CONFIG_HOME: path.join(home, '.config'), + MOSS_LANG: lang, + NO_COLOR: '1', + }, + encoding: 'utf8', + }); + const en = run('en'); + assert.match(en.stdout, /--accept-edits: auto-approve file edits.*still ask.*Claude Code/); + const zh = run('zh'); + assert.match(zh.stdout, /--accept-edits:自动通过本文件夹内的文件编辑.*Claude Code/); + fs.rmSync(home, { recursive: true, force: true }); +} + +console.log('o72-approval-mode-copy: ok'); diff --git a/test/workspace-write-copy.spec.mjs b/test/workspace-write-copy.spec.mjs index c8c5d7cb..382fcda2 100644 --- a/test/workspace-write-copy.spec.mjs +++ b/test/workspace-write-copy.spec.mjs @@ -119,12 +119,12 @@ const runtime = { { assert.ok( - formatFullModeNotice('en_US.UTF-8').includes(WORKSPACE_WRITE_LIMIT_EN), - 'startup notice (en) states the limit' + formatFullModeNotice('en_US.UTF-8').includes('without asking'), + 'startup notice (en) states that full mode does not ask (O72, copy-audit 1.9)' ); assert.ok( - formatFullModeNotice('zh_CN.UTF-8').includes(WORKSPACE_WRITE_LIMIT_ZH), - 'startup notice (zh) states the limit' + formatFullModeNotice('zh_CN.UTF-8').includes('不逐条询问'), + 'startup notice (zh) states that full mode does not ask (O72, copy-audit 1.9)' ); const wrapped = wrapNoticeLines(formatFullModeNotice('C'), 60); assert.ok(wrapped.length > 1, 'a 60-column safety notice wraps'); From 89525f37d5184a47309860f5af5b14e568f248bb Mon Sep 17 00:00:00 2001 From: Moss Developer Date: Sun, 11 Oct 2026 14:59:52 +0800 Subject: [PATCH 2/2] fix(o72): keep no-sandbox disclosure, once-per-folder untrusted notice, /mode accept-edits help - full-mode notice keeps 'Shell commands run with your user's full permissions (no sandbox).' (en/zh) - untrusted-folder skipped-settings line shows once per folder; shown again if the list changes - /mode help (onboarding + registry) says shell commands still ask under accept-edits - o72 spec: stderr after a headless block names --full-access and /permissions (accept-edits and manual); --workspace-write -> manual lock; once-per-folder unit + CLI test; /mode help en/zh --- src/cli-main.ts | 8 +- src/cli/cli-locale.ts | 12 ++- src/cli/commands/registry.ts | 4 +- src/cli/config.ts | 44 ++++++++++ src/cli/onboarding.ts | 4 +- test/o72-approval-mode-copy.spec.mjs | 115 +++++++++++++++++++++++++++ 6 files changed, 178 insertions(+), 9 deletions(-) diff --git a/src/cli-main.ts b/src/cli-main.ts index d7e9256f..fe8242e9 100644 --- a/src/cli-main.ts +++ b/src/cli-main.ts @@ -26,6 +26,7 @@ import { resolveConfigDir, safeProcessCwd, shouldShowFullDefaultNotice, + shouldShowUntrustedFolderNotice, type LoadedCliConfigFile, } from './cli/config.js'; import { parseCliArgs, shouldOpenResumePicker } from './cli/args.js'; @@ -1094,8 +1095,11 @@ async function main() { ...(summary ? [summary] : []), ]; const line = untrustedFolderLine(parts, isZhLocale()); - if (useTui) emitTuiNotice(line); - else console.error(line); + // O72: once per folder (and again only if the skipped list changes). + if (shouldShowUntrustedFolderNotice(startup.folderKey, line)) { + if (useTui) emitTuiNotice(line); + else console.error(line); + } } else if (startup.ignoredRoutingEnv.length > 0) { const line = ancestorRoutingIgnoredLine( startup.ignoredRoutingEnv, diff --git a/src/cli/cli-locale.ts b/src/cli/cli-locale.ts index 6c6280da..b9d7c4c2 100644 --- a/src/cli/cli-locale.ts +++ b/src/cli/cli-locale.ts @@ -354,14 +354,20 @@ export function wrapNoticeLines(text: string, columns = 80): string[] { return wrap(text, Math.max(20, columns)); } -/** One-shot notice when full mode has no deny rules (copy-audit 1.9). */ +/** + * One-shot notice when full mode has no deny rules (copy-audit 1.9). + * The no-sandbox sentence is a safety disclosure (QA review of O72): keep it. + * `--workspace-write` is named as the per-action route because it resolves to + * manual mode (args.ts requestSafety), where every non-read-only exec asks. + */ export function formatFullModeNotice(locale?: string): string { const zh = isZhLocale(locale); return zh - ? '[moss] 完全执行模式:Moss 会直接改文件、跑命令,不逐条询问。想逐条确认:/mode manual(或启动时加 --workspace-write)。要拦截特定操作:/permissions。此提示只显示一次。' + ? '[moss] 完全执行模式:Moss 会直接改文件、跑命令,不逐条询问。想逐条确认:/mode manual(或启动时加 --workspace-write)。要拦截特定操作:/permissions。终端命令以你的用户权限直接运行,没有沙箱。此提示只显示一次。' : '[moss] Full mode: Moss edits files and runs commands without asking. ' + 'To approve each action: /mode manual (or start with --workspace-write). ' + - 'Block specific things with /permissions. This notice shows once.'; + 'Block specific things with /permissions. ' + + "Shell commands run with your user's full permissions (no sandbox). This notice shows once."; } /** diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index aa2d8548..410af0f4 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -322,7 +322,7 @@ const modeCommand: CommandSpec = { ? [ `当前交互模式:${label}`, ' /mode manual 正常编码(逐次审批写操作与设备变更)', - ' /mode accept-edits 自动接受工作区内文件工具编辑', + ' /mode accept-edits 自动通过本文件夹内的文件编辑;终端命令仍需确认(-p 下会被拦下)', ' /mode plan 只读规划(不执行写操作)', ' /mode full 全开:跳过询问,仅 deny 规则与硬拦截生效(默认)', ' 快捷键:Shift+Tab 在四种模式间循环', @@ -331,7 +331,7 @@ const modeCommand: CommandSpec = { : [ `Interaction mode: ${label}`, ' /mode manual normal coding (approve mutations one by one)', - ' /mode accept-edits auto-approve workspace file-tool edits', + ' /mode accept-edits auto-approve file edits in this folder; shell commands still ask (blocked in -p)', ' /mode plan read-only planning (block mutations)', ' /mode full skip prompts — only deny rules and hard blocks apply (default)', ' Shortcut: Shift+Tab cycles manual / accept-edits / plan / full', diff --git a/src/cli/config.ts b/src/cli/config.ts index 9db768a8..9c551488 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -1692,6 +1692,50 @@ export function hasTrustedToolWildcard(config: Pick(); + +/** + * O72 / copy-audit 1.3: the "this folder isn't trusted, project settings were + * skipped" line shows once per folder, not on every `-p`. Keyed by folder and + * by the exact skipped-settings line, so a NEW skipped setting (or a changed + * list) is shown again. Stored per config dir; an unreadable or unwritable + * store falls back to showing the line (never hides it silently forever). + */ +export function shouldShowUntrustedFolderNotice( + folderKey: string, + line: string, + env: NodeJS.ProcessEnv = process.env +): boolean { + if (!line) return false; + const dir = resolveConfigDir(env); + const memoKey = `${dir}\0${folderKey}\0${line}`; + if (shownUntrustedFolderNotice.has(memoKey)) return false; + shownUntrustedFolderNotice.add(memoKey); + const file = path.join(dir, UNTRUSTED_FOLDER_NOTICE_FILE); + let seen: Record = {}; + try { + const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8')); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + seen = parsed as Record; + } + } catch { + /* missing or corrupt store: treat as not shown */ + } + if (seen[folderKey] === line) return false; + seen[folderKey] = line; + try { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(file, `${JSON.stringify(seen, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600, + }); + } catch { + /* not writable: show now, and again next run */ + } + return true; +} + const FULL_DEFAULT_NOTICE_MARKER = '.full_default_notice_shown'; const shownFullDefaultNotice = new Set(); diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts index 4b60fad0..f4a569ca 100644 --- a/src/cli/onboarding.ts +++ b/src/cli/onboarding.ts @@ -416,7 +416,7 @@ function permissionsHelpText(): string { '', ' 模式只有一根轴 — /mode(Shift+Tab 在四种状态间循环,v0.26):', ' /mode manual 逐项确认变更', - ' /mode accept-edits 自动接受工作区文件工具的编辑', + ' /mode accept-edits 自动通过本文件夹内的文件编辑;终端命令仍需确认(-p 下会被拦下)', ' /mode plan 只读规划(变更会被拦住)', ' /mode full 不再询问(默认)— deny 规则仍然生效', '', @@ -436,7 +436,7 @@ function permissionsHelpText(): string { '', ' One mode axis — /mode (Shift+Tab cycles four states, v0.26):', ' /mode manual approve mutations one by one', - ' /mode accept-edits auto-approve workspace file-tool edits', + ' /mode accept-edits auto-approve file edits in this folder; shell commands still ask (blocked in -p)', ' /mode plan read-only planning (mutations blocked)', ' /mode full skip prompts (default) — deny rules still apply', '', diff --git a/test/o72-approval-mode-copy.spec.mjs b/test/o72-approval-mode-copy.spec.mjs index 2616831d..4b6bdf04 100644 --- a/test/o72-approval-mode-copy.spec.mjs +++ b/test/o72-approval-mode-copy.spec.mjs @@ -19,6 +19,8 @@ import { fileURLToPath } from 'node:url'; import { createCliToolApprovalHook } from '../dist/cli/approval.js'; import { parseCliArgs } from '../dist/cli/args.js'; import { deriveEngineQuantas } from '../dist/cli/interaction-mode.js'; +import { shouldShowUntrustedFolderNotice } from '../dist/cli/config.js'; +import { findRegistryCommand } from '../dist/cli/commands/registry.js'; import { formatFullModeNotice, formatHeadlessBlockNotice, @@ -62,6 +64,18 @@ assert.match(formatInteractionModeNotice('acceptEdits', 'C'), /shell commands st assert.match(formatFullModeNotice('C'), /edits files and runs commands without asking/); assert.match(formatFullModeNotice('C'), /\/mode manual/); assert.match(formatFullModeNotice('zh_CN'), /不逐条询问/); +// QA review: the no-sandbox disclosure stays in the full-mode notice. +assert.ok( + formatFullModeNotice('C').includes( + "Shell commands run with your user's full permissions (no sandbox)." + ), + formatFullModeNotice('C') +); +assert.ok(formatFullModeNotice('zh_CN').includes('终端命令以你的用户权限直接运行,没有沙箱。')); +// --workspace-write is offered as the per-action route only because it +// resolves to manual (every non-read-only exec asks). Lock that mapping. +assert.equal(parseCliArgs(['--workspace-write'], {}).interactionModeOverride, 'manual'); +assert.equal(deriveEngineQuantas('manual').approvalPolicy, 'prompt'); // 3. user-facing block line names the flag and the way out. assert.match( @@ -123,6 +137,10 @@ assert.match(formatHeadlessBlockReason('exec', 'acceptEdits'), /accept-edits/); errs.some((l) => /Blocked: "exec".*--accept-edits/.test(l)), `stderr notice: ${errs.join('|')}` ); + // The user (stderr), not the model, is told how to unlock it. + const blockLine = errs.find((l) => /Blocked: "exec"/.test(l)) ?? ''; + assert.ok(blockLine.includes('--full-access'), `stderr names --full-access: ${blockLine}`); + assert.ok(blockLine.includes('/permissions'), `stderr names /permissions: ${blockLine}`); const edit = await hook({ tool: tool('edit_file', 'local_write'), input: { path: 'notes.txt', old_string: 'a', new_string: 'b' }, @@ -156,4 +174,101 @@ assert.match(formatHeadlessBlockReason('exec', 'acceptEdits'), /accept-edits/); fs.rmSync(home, { recursive: true, force: true }); } +// 5b. same for manual mode (e.g. --workspace-write) in a non-interactive run. +{ + const savedTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + const errs = []; + const origErr = console.error; + console.error = (...a) => errs.push(a.join(' ')); + try { + const hook = createCliToolApprovalHook( + 'workspace-write', + {}, + { workspaceDir: process.cwd(), interactionMode: () => 'manual' } + ); + const exec = await hook({ + tool: { + name: 'exec', + description: 'exec', + inputSchema: { type: 'object', properties: {} }, + metadata: { sideEffectClass: 'local_write' }, + execute: async () => 'ok', + }, + input: { command: 'touch o72-marker' }, + sessionKey: 'o72m', + }); + assert.equal(exec.approved, false, 'manual + no TTY blocks a writing exec'); + const line = errs.find((l) => /Blocked: "exec"/.test(l)) ?? ''; + assert.ok(line.includes('--full-access') && line.includes('/permissions'), line); + for (const re of FULL_COACHING) assert.doesNotMatch(exec.reason, re); + } finally { + console.error = origErr; + Object.defineProperty(process.stdin, 'isTTY', { value: savedTTY, configurable: true }); + } +} + +// 7. skipped-project-settings notice: once per folder; shown again when the +// skipped list changes or for another folder; empty line never shown. +{ + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'o72-untrusted-')); + const env = { MOSS_CONFIG_DIR: path.join(home, 'cfg'), HOME: home }; + const a = '[moss] skipped: .moss/config.json'; + assert.equal(shouldShowUntrustedFolderNotice('/w/one', '', env), false, 'empty line hidden'); + assert.equal(shouldShowUntrustedFolderNotice('/w/one', a, env), true, 'first time shown'); + assert.equal(shouldShowUntrustedFolderNotice('/w/one', a, env), false, 'same process: once'); + assert.equal(shouldShowUntrustedFolderNotice('/w/two', a, env), true, 'other folder shown'); + const b = '[moss] skipped: .moss/config.json, hooks'; + assert.equal(shouldShowUntrustedFolderNotice('/w/one', b, env), true, 'changed list shown again'); + fs.rmSync(home, { recursive: true, force: true }); +} + +// 7b. once per folder across processes (real CLI, -p, untrusted folder with +// project routing settings). +{ + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'o72-untrusted-cli-')); + const proj = path.join(home, 'proj'); + fs.mkdirSync(path.join(proj, '.moss'), { recursive: true }); + fs.writeFileSync( + path.join(proj, '.moss', 'config.json'), + JSON.stringify({ baseUrl: 'http://127.0.0.1:9/v1' }) + ); + const run = () => + spawnSync(process.execPath, [path.join(root, 'dist/cli.js'), '--mock', '-p', 'hi'], { + cwd: proj, + env: { + PATH: process.env.PATH, + HOME: home, + XDG_CONFIG_HOME: path.join(home, '.config'), + MOSS_LANG: 'en', + NO_COLOR: '1', + }, + encoding: 'utf8', + timeout: 60_000, + }); + const count = (r) => (r.stderr.match(/project settings/gi) ?? []).length; + const first = run(); + const second = run(); + assert.equal(count(first), 1, `first run shows it once: ${first.stderr}`); + assert.equal(count(second), 0, `second run in the same folder is silent: ${second.stderr}`); + fs.rmSync(home, { recursive: true, force: true }); +} + +// 8. /mode help: accept-edits says shell commands still ask (en + zh). +{ + for (const [locale, re] of [ + [ + 'en_US.UTF-8', + /\/mode accept-edits\s+auto-approve file edits in this folder; shell commands still ask/, + ], + ['zh_CN.UTF-8', /\/mode accept-edits\s+自动通过本文件夹内的文件编辑;终端命令仍需确认/], + ]) { + const match = findRegistryCommand('/mode'); + assert.ok(match, '/mode is registered'); + const said = []; + await match.spec.run({ locale, say: (_k, t) => said.push(t) }, ''); + assert.match(said.join('\n'), re, `/mode help (${locale}): ${said.join('\n')}`); + } +} + console.log('o72-approval-mode-copy: ok');