Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/cli-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
resolveConfigDir,
safeProcessCwd,
shouldShowFullDefaultNotice,
shouldShowUntrustedFolderNotice,
type LoadedCliConfigFile,
} from './cli/config.js';
import { parseCliArgs, shouldOpenResumePicker } from './cli/args.js';
Expand Down Expand Up @@ -630,10 +631,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());
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 5 additions & 8 deletions src/cli/approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1263,20 +1263,17 @@ 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;
}
return settle({
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),
});
}

Expand Down
71 changes: 57 additions & 14 deletions src/cli/cli-locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,16 +333,17 @@ export function formatInteractionModeNotice(mode: string, locale?: string): stri
const zh = isZhLocale(locale);
const labels: Record<string, string> = 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}`;
Expand All @@ -353,15 +354,57 @@ 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).
* 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);
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. ' +
"Shell commands run with your user's full permissions (no sandbox). 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<UiLanguageSource, string> = {
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ const modeCommand: CommandSpec = {
? [
`当前交互模式:${label}`,
' /mode manual 正常编码(逐次审批写操作与设备变更)',
' /mode accept-edits 自动接受工作区内文件工具编辑',
' /mode accept-edits 自动通过本文件夹内的文件编辑;终端命令仍需确认(-p 下会被拦下)',
' /mode plan 只读规划(不执行写操作)',
' /mode full 全开:跳过询问,仅 deny 规则与硬拦截生效(默认)',
' 快捷键:Shift+Tab 在四种模式间循环',
Expand All @@ -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',
Expand Down
44 changes: 44 additions & 0 deletions src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,50 @@ export function hasTrustedToolWildcard(config: Pick<ResolvedCliConfig, 'trustedT
* The marker lives next to the user config (not the workspace). Doctor still
* reports the same condition on every run.
*/
const UNTRUSTED_FOLDER_NOTICE_FILE = 'untrusted-folder-notices.json';
const shownUntrustedFolderNotice = new Set<string>();

/**
* 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<string, string> = {};
try {
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
seen = parsed as Record<string, string>;
}
} 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<string>();

Expand Down
4 changes: 2 additions & 2 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function fullHelpLines(c: Colors, configPath: string, zh = false): string
` ${c.yellow('-C, --cd')} <dir> 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')} <never|prompt> other mode overrides (mutually exclusive)`,
` ${c.yellow('--accept-edits')} · ${c.yellow('--plan')} · ${c.yellow('--ask-for-approval')} <never|prompt> 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')} <f> offline · machine-readable output`,
` ${c.yellow('--quiet')} · ${c.yellow('--verbose')} · ${c.yellow('--debug')} · ${c.yellow('--no-color')}`,
` ${c.bold('Environment')}`,
Expand Down Expand Up @@ -166,7 +166,7 @@ function fullHelpLinesZh(c: Colors, configPath: string, interactiveLines: string
` ${c.yellow('-C, --cd')} <dir> 换一个工作区`,
` ${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')} <never|prompt> 其它互斥的模式覆盖`,
` ${c.yellow('--accept-edits')} · ${c.yellow('--plan')} · ${c.yellow('--ask-for-approval')} <never|prompt> 其它互斥的模式覆盖。--accept-edits:自动通过本文件夹内的文件编辑;终端命令(包括 mkdir/mv/rm)仍需确认,比 Claude Code 的 acceptEdits 窄;-p 下没人确认会被拦下(需要时用 --full-access)`,
` ${c.yellow('--mock')} · ${c.yellow('--json')} · ${c.yellow('--output-format')} <f> 离线 · 机器可读输出`,
` ${c.yellow('--quiet')} · ${c.yellow('--verbose')} · ${c.yellow('--debug')} · ${c.yellow('--no-color')}`,
` ${c.bold('环境变量')}`,
Expand Down
4 changes: 2 additions & 2 deletions src/cli/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 规则仍然生效',
'',
Expand All @@ -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',
'',
Expand Down
23 changes: 8 additions & 15 deletions test/cli-permission-defaults.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ─────────
Expand Down
4 changes: 2 additions & 2 deletions test/cli-project-instructions.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
}
3 changes: 2 additions & 1 deletion test/cli-ui-language.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions test/first-run.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────
Expand Down Expand Up @@ -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/],
},
Expand Down
Loading
Loading