mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 08:40:58 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f59385e0c | |||
| f5bf268117 | |||
| 322b866a5a |
@@ -35,6 +35,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { planCommand } from '../ui/commands/planCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { policiesCommand } from '../ui/commands/policiesCommand.js';
|
||||
import { profileCommand } from '../ui/commands/profileCommand.js';
|
||||
@@ -131,6 +132,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [mcpCommand]),
|
||||
memoryCommand,
|
||||
modelCommand,
|
||||
...(this.config?.isPlanEnabled() ? [planCommand] : []),
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
privacyCommand,
|
||||
policiesCommand,
|
||||
|
||||
@@ -97,6 +97,7 @@ import { useFocus } from './hooks/useFocus.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from './keyMatchers.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { usePlanMonitoring } from './hooks/usePlanMonitoring.js';
|
||||
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
|
||||
@@ -1334,6 +1335,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
retryStatus,
|
||||
});
|
||||
|
||||
const { planTodos, planFileName } = usePlanMonitoring(config);
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
if (copyModeEnabled) {
|
||||
@@ -1688,6 +1691,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
constrainHeight,
|
||||
showErrorDetails,
|
||||
showFullTodos,
|
||||
planTodos,
|
||||
planFileName,
|
||||
filteredConsoleMessages,
|
||||
ideContextState,
|
||||
renderMarkdown,
|
||||
@@ -1786,6 +1791,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
constrainHeight,
|
||||
showErrorDetails,
|
||||
showFullTodos,
|
||||
planTodos,
|
||||
planFileName,
|
||||
filteredConsoleMessages,
|
||||
ideContextState,
|
||||
renderMarkdown,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { isDirectorySecure } from '@google/gemini-cli-core';
|
||||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
export const planCommand: SlashCommand = {
|
||||
name: 'plan',
|
||||
description: 'Manage implementation plans',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'discard',
|
||||
description: 'Discard all plan files in the current session',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
const config = context.services.config;
|
||||
if (!config) return;
|
||||
|
||||
const plansDir = config.storage.getProjectTempPlansDir();
|
||||
try {
|
||||
const securityCheck = await isDirectorySecure(plansDir, {
|
||||
owner: 'user',
|
||||
});
|
||||
if (!securityCheck.secure) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Security check failed for plans directory: ${securityCheck.reason}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fs.existsSync(plansDir)) {
|
||||
const files = await fs.promises.readdir(plansDir);
|
||||
const mdFiles = files.filter((f) => f.endsWith('.md'));
|
||||
|
||||
for (const file of mdFiles) {
|
||||
await fs.promises.unlink(path.join(plansDir, file));
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Discarded ${mdFiles.length} plan file(s).`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} else {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'No plan files found to discard.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to discard plans: ${(error as Error).message}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -4,15 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { renderWithProviders as render } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Box } from 'ink';
|
||||
import { TodoTray } from './Todo.js';
|
||||
import type { Todo } from '@google/gemini-cli-core';
|
||||
import type { UIState } from '../../contexts/UIStateContext.js';
|
||||
import { UIStateContext } from '../../contexts/UIStateContext.js';
|
||||
import type { HistoryItem } from '../../types.js';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import type { UIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
const createTodoHistoryItem = (todos: Todo[]): HistoryItem =>
|
||||
({
|
||||
@@ -34,21 +32,18 @@ describe.each([true, false])(
|
||||
'<TodoTray /> (showFullTodos: %s)',
|
||||
(showFullTodos: boolean) => {
|
||||
const renderWithUiState = (uiState: Partial<UIState>) =>
|
||||
render(
|
||||
<UIStateContext.Provider value={uiState as UIState}>
|
||||
<TodoTray />
|
||||
</UIStateContext.Provider>,
|
||||
);
|
||||
render(<TodoTray />, {
|
||||
uiState: { ...uiState, showFullTodos },
|
||||
});
|
||||
|
||||
it('renders null when no todos are in the history', () => {
|
||||
const { lastFrame } = renderWithUiState({ history: [], showFullTodos });
|
||||
const { lastFrame } = renderWithUiState({ history: [] });
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders null when todo list is empty', () => {
|
||||
const { lastFrame } = renderWithUiState({
|
||||
history: [createTodoHistoryItem([])],
|
||||
showFullTodos,
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
@@ -62,7 +57,6 @@ describe.each([true, false])(
|
||||
{ description: 'Completed Task', status: 'completed' },
|
||||
]),
|
||||
],
|
||||
showFullTodos,
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
@@ -77,39 +71,31 @@ describe.each([true, false])(
|
||||
{ description: 'Completed Task', status: 'completed' },
|
||||
]),
|
||||
],
|
||||
showFullTodos,
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders a todo list with long descriptions that wrap when full view is on', () => {
|
||||
const { lastFrame } = render(
|
||||
<Box width="50">
|
||||
<UIStateContext.Provider
|
||||
value={
|
||||
const { lastFrame } = render(<TodoTray />, {
|
||||
width: 50,
|
||||
uiState: {
|
||||
history: [
|
||||
createTodoHistoryItem([
|
||||
{
|
||||
history: [
|
||||
createTodoHistoryItem([
|
||||
{
|
||||
description:
|
||||
'This is a very long description for a pending task that should wrap around multiple lines when the terminal width is constrained.',
|
||||
status: 'in_progress',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Another completed task with an equally verbose description to test wrapping behavior.',
|
||||
status: 'completed',
|
||||
},
|
||||
]),
|
||||
],
|
||||
showFullTodos,
|
||||
} as UIState
|
||||
}
|
||||
>
|
||||
<TodoTray />
|
||||
</UIStateContext.Provider>
|
||||
</Box>,
|
||||
);
|
||||
description:
|
||||
'This is a very long description for a pending task that should wrap around multiple lines when the terminal width is constrained.',
|
||||
status: 'in_progress',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Another completed task with an equally verbose description to test wrapping behavior.',
|
||||
status: 'completed',
|
||||
},
|
||||
]),
|
||||
],
|
||||
showFullTodos,
|
||||
},
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -125,7 +111,6 @@ describe.each([true, false])(
|
||||
{ description: 'Newer Task 2', status: 'in_progress' },
|
||||
]),
|
||||
],
|
||||
showFullTodos,
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
@@ -138,9 +123,34 @@ describe.each([true, false])(
|
||||
{ description: 'Task 2', status: 'cancelled' },
|
||||
]),
|
||||
],
|
||||
showFullTodos,
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders planTodos instead of history todos when available', () => {
|
||||
const { lastFrame } = renderWithUiState({
|
||||
history: [
|
||||
createTodoHistoryItem([
|
||||
{ description: 'History Task', status: 'pending' },
|
||||
]),
|
||||
],
|
||||
planTodos: [
|
||||
{ description: 'Plan Task 1', status: 'in_progress' },
|
||||
{ description: 'Plan Task 2', status: 'pending' },
|
||||
],
|
||||
planFileName: 'implementation.md',
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders planTodos with the correct filename in the header', () => {
|
||||
const { lastFrame } = renderWithUiState({
|
||||
planTodos: [{ description: 'Only Task', status: 'pending' }],
|
||||
planFileName: 'my-feature.md',
|
||||
showFullTodos: true,
|
||||
});
|
||||
expect(lastFrame()).toContain('Plan: my-feature.md');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import {
|
||||
type Todo,
|
||||
type TodoList,
|
||||
@@ -16,7 +17,10 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useMemo } from 'react';
|
||||
import type { HistoryItemToolGroup } from '../../types.js';
|
||||
|
||||
const TodoTitleDisplay: React.FC<{ todos: TodoList }> = ({ todos }) => {
|
||||
const TodoTitleDisplay: React.FC<{
|
||||
todos: TodoList;
|
||||
fileName?: string | null;
|
||||
}> = ({ todos, fileName }) => {
|
||||
const score = useMemo(() => {
|
||||
let total = 0;
|
||||
let completed = 0;
|
||||
@@ -34,7 +38,7 @@ const TodoTitleDisplay: React.FC<{ todos: TodoList }> = ({ todos }) => {
|
||||
return (
|
||||
<Box flexDirection="row" columnGap={2} height={1}>
|
||||
<Text color={theme.text.primary} bold aria-label="Todo list">
|
||||
Todo
|
||||
{fileName ? `Plan: ${stripAnsi(fileName)}` : 'Todo'}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>{score} (ctrl+t to toggle)</Text>
|
||||
</Box>
|
||||
@@ -105,6 +109,9 @@ export const TodoTray: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
|
||||
const todos: TodoList | null = useMemo(() => {
|
||||
if (uiState.planTodos && uiState.planTodos.length > 0) {
|
||||
return { todos: uiState.planTodos };
|
||||
}
|
||||
// Find the most recent todo list written by the WriteTodosTool
|
||||
for (let i = uiState.history.length - 1; i >= 0; i--) {
|
||||
const entry = uiState.history[i];
|
||||
@@ -123,7 +130,7 @@ export const TodoTray: React.FC = () => {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [uiState.history]);
|
||||
}, [uiState.history, uiState.planTodos]);
|
||||
|
||||
const inProgress: Todo | null = useMemo(() => {
|
||||
if (todos === null) {
|
||||
@@ -148,6 +155,8 @@ export const TodoTray: React.FC = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPlan = uiState.planTodos && uiState.planTodos.length > 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
@@ -160,13 +169,19 @@ export const TodoTray: React.FC = () => {
|
||||
>
|
||||
{uiState.showFullTodos ? (
|
||||
<Box flexDirection="column" rowGap={1}>
|
||||
<TodoTitleDisplay todos={todos} />
|
||||
<TodoTitleDisplay
|
||||
todos={todos}
|
||||
fileName={isPlan ? uiState.planFileName : undefined}
|
||||
/>
|
||||
<TodoListDisplay todos={todos} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="row" columnGap={1} height={1}>
|
||||
<Box flexShrink={0} flexGrow={0}>
|
||||
<TodoTitleDisplay todos={todos} />
|
||||
<TodoTitleDisplay
|
||||
todos={todos}
|
||||
fileName={isPlan ? uiState.planFileName : undefined}
|
||||
/>
|
||||
</Box>
|
||||
{inProgress && (
|
||||
<Box flexShrink={1} flexGrow={1}>
|
||||
|
||||
@@ -11,18 +11,28 @@ exports[`<TodoTray /> (showFullTodos: false) > renders null when no todos are in
|
||||
|
||||
exports[`<TodoTray /> (showFullTodos: false) > renders null when todo list is empty 1`] = `""`;
|
||||
|
||||
exports[`<TodoTray /> (showFullTodos: false) > renders planTodos instead of history todos when available 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Plan: implementation.md 0/2 completed (ctrl+t to toggle) » Plan Task 1"
|
||||
`;
|
||||
|
||||
exports[`<TodoTray /> (showFullTodos: false) > renders planTodos with the correct filename in the header 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Plan: my-feature.md 0/1 completed (ctrl+t to toggle)"
|
||||
`;
|
||||
|
||||
exports[`<TodoTray /> (showFullTodos: false) > renders the most recent todo list when multiple write_todos calls are in history 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Todo 0/2 completed (ctrl+t to toggle) » Newer Task 2"
|
||||
`;
|
||||
|
||||
exports[`<TodoTray /> (showFullTodos: false) > renders when todos exist and one is in progress 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Todo 1/3 completed (ctrl+t to toggle) » Task 2"
|
||||
`;
|
||||
|
||||
exports[`<TodoTray /> (showFullTodos: false) > renders when todos exist but none are in progress 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Todo 1/2 completed (ctrl+t to toggle)"
|
||||
`;
|
||||
|
||||
@@ -38,7 +48,7 @@ exports[`<TodoTray /> (showFullTodos: true) > renders a todo list with long desc
|
||||
`;
|
||||
|
||||
exports[`<TodoTray /> (showFullTodos: true) > renders full list when all todos are inactive 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Todo 1/1 completed (ctrl+t to toggle)
|
||||
|
||||
✓ Task 1
|
||||
@@ -49,8 +59,23 @@ exports[`<TodoTray /> (showFullTodos: true) > renders null when no todos are in
|
||||
|
||||
exports[`<TodoTray /> (showFullTodos: true) > renders null when todo list is empty 1`] = `""`;
|
||||
|
||||
exports[`<TodoTray /> (showFullTodos: true) > renders planTodos instead of history todos when available 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Plan: implementation.md 0/2 completed (ctrl+t to toggle)
|
||||
|
||||
» Plan Task 1
|
||||
☐ Plan Task 2"
|
||||
`;
|
||||
|
||||
exports[`<TodoTray /> (showFullTodos: true) > renders planTodos with the correct filename in the header 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Plan: my-feature.md 0/1 completed (ctrl+t to toggle)
|
||||
|
||||
☐ Only Task"
|
||||
`;
|
||||
|
||||
exports[`<TodoTray /> (showFullTodos: true) > renders the most recent todo list when multiple write_todos calls are in history 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Todo 0/2 completed (ctrl+t to toggle)
|
||||
|
||||
☐ Newer Task 1
|
||||
@@ -58,7 +83,7 @@ exports[`<TodoTray /> (showFullTodos: true) > renders the most recent todo list
|
||||
`;
|
||||
|
||||
exports[`<TodoTray /> (showFullTodos: true) > renders when todos exist and one is in progress 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Todo 1/3 completed (ctrl+t to toggle)
|
||||
|
||||
☐ Pending Task
|
||||
@@ -68,7 +93,7 @@ exports[`<TodoTray /> (showFullTodos: true) > renders when todos exist and one i
|
||||
`;
|
||||
|
||||
exports[`<TodoTray /> (showFullTodos: true) > renders when todos exist but none are in progress 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Todo 1/2 completed (ctrl+t to toggle)
|
||||
|
||||
☐ Pending Task
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
FallbackIntent,
|
||||
ValidationIntent,
|
||||
AgentDefinition,
|
||||
Todo,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { DOMElement } from 'ink';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
@@ -144,6 +145,8 @@ export interface UIState {
|
||||
embeddedShellFocused: boolean;
|
||||
showDebugProfiler: boolean;
|
||||
showFullTodos: boolean;
|
||||
planTodos: Todo[] | null;
|
||||
planFileName: string | null;
|
||||
copyModeEnabled: boolean;
|
||||
warningMessage: string | null;
|
||||
bannerData: {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { usePlanMonitoring } from './usePlanMonitoring.js';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { act } from 'react';
|
||||
|
||||
const { mockFsPromises } = vi.hoisted(() => ({
|
||||
mockFsPromises: {
|
||||
readdir: vi.fn(),
|
||||
lstat: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
lstatSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
realpathSync: vi.fn(),
|
||||
promises: mockFsPromises,
|
||||
default: {
|
||||
existsSync: vi.fn(),
|
||||
lstatSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
realpathSync: vi.fn(),
|
||||
promises: mockFsPromises,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => mockFsPromises);
|
||||
|
||||
describe('usePlanMonitoring', () => {
|
||||
let mockConfig: Config;
|
||||
const plansDir = path.join(os.tmpdir(), 'test-plans');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === plansDir || pathStr === os.tmpdir()) return true;
|
||||
return false;
|
||||
});
|
||||
vi.mocked(fs.lstatSync).mockImplementation((p) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === plansDir || pathStr === os.tmpdir()) {
|
||||
return { isDirectory: () => true } as fs.Stats;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
vi.mocked(fs.statSync).mockImplementation((p) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr === plansDir || pathStr === os.tmpdir()) {
|
||||
return { isDirectory: () => true } as fs.Stats;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
||||
|
||||
mockConfig = makeFakeConfig({
|
||||
targetDir: os.tmpdir(),
|
||||
});
|
||||
vi.spyOn(mockConfig.storage, 'getProjectTempPlansDir').mockReturnValue(
|
||||
plansDir,
|
||||
);
|
||||
mockFsPromises.readdir.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('initially has null todos and planFileName', async () => {
|
||||
mockFsPromises.readdir.mockResolvedValue([]);
|
||||
const { result } = renderHook(() => usePlanMonitoring(mockConfig));
|
||||
|
||||
expect(result.current.planTodos).toBeNull();
|
||||
expect(result.current.planFileName).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the most recently modified markdown file', async () => {
|
||||
const file1 = 'old.md';
|
||||
const file2 = 'new.md';
|
||||
|
||||
mockFsPromises.readdir.mockResolvedValue([file1, file2]);
|
||||
mockFsPromises.lstat.mockImplementation(async (p) => {
|
||||
const pathStr = p.toString();
|
||||
if (pathStr.endsWith(file1))
|
||||
return { isFile: () => true, mtimeMs: 100 } as fs.Stats;
|
||||
if (pathStr.endsWith(file2))
|
||||
return { isFile: () => true, mtimeMs: 200 } as fs.Stats;
|
||||
return { isFile: () => false } as fs.Stats;
|
||||
});
|
||||
mockFsPromises.readFile.mockResolvedValue('- [ ] Task from new.md');
|
||||
|
||||
const { result } = renderHook(() => usePlanMonitoring(mockConfig));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.planFileName).toBe(file2);
|
||||
expect(result.current.planTodos).toEqual([
|
||||
{ description: 'Task from new.md', status: 'pending' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates todos when the file content changes', async () => {
|
||||
mockFsPromises.readdir.mockResolvedValue(['plan.md']);
|
||||
mockFsPromises.lstat.mockResolvedValue({
|
||||
isFile: () => true,
|
||||
mtimeMs: 100,
|
||||
} as fs.Stats);
|
||||
|
||||
mockFsPromises.readFile.mockResolvedValueOnce('- [ ] Initial Task');
|
||||
|
||||
const { result } = renderHook(() => usePlanMonitoring(mockConfig));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.planTodos?.[0].description).toBe('Initial Task');
|
||||
});
|
||||
|
||||
// Change mtime and content
|
||||
mockFsPromises.lstat.mockResolvedValue({
|
||||
isFile: () => true,
|
||||
mtimeMs: 200,
|
||||
} as fs.Stats);
|
||||
mockFsPromises.readFile.mockResolvedValueOnce('- [x] Completed Task');
|
||||
|
||||
// Advance timers to trigger interval
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.planTodos?.[0].description).toBe('Completed Task');
|
||||
expect(result.current.planTodos?.[0].status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears todos when no markdown files are found', async () => {
|
||||
mockFsPromises.readdir.mockResolvedValueOnce(['plan.md']);
|
||||
mockFsPromises.lstat.mockResolvedValue({
|
||||
isFile: () => true,
|
||||
mtimeMs: 100,
|
||||
} as fs.Stats);
|
||||
mockFsPromises.readFile.mockResolvedValue('- [ ] Task');
|
||||
|
||||
const { result } = renderHook(() => usePlanMonitoring(mockConfig));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.planTodos).not.toBeNull();
|
||||
});
|
||||
|
||||
mockFsPromises.readdir.mockResolvedValueOnce([]);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.planTodos).toBeNull();
|
||||
expect(result.current.planFileName).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
parseMarkdownTodos,
|
||||
type Todo,
|
||||
type Config,
|
||||
debugLogger,
|
||||
isDirectorySecure,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export function usePlanMonitoring(config: Config) {
|
||||
const [planTodos, setPlanTodos] = useState<Todo[] | null>(null);
|
||||
const [planFileName, setPlanFileName] = useState<string | null>(null);
|
||||
const planFileNameRef = useRef<string | null>(null);
|
||||
const lastModifiedRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const plansDir = config.storage.getProjectTempPlansDir();
|
||||
|
||||
const updatePlan = async () => {
|
||||
try {
|
||||
const securityCheck = await isDirectorySecure(plansDir, {
|
||||
owner: 'user',
|
||||
});
|
||||
if (!securityCheck.secure) {
|
||||
debugLogger.warn(
|
||||
'Security check failed for plans directory',
|
||||
securityCheck.reason,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(plansDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await fs.promises.readdir(plansDir);
|
||||
const mdFiles = files.filter((f) => f.endsWith('.md'));
|
||||
|
||||
if (mdFiles.length === 0) {
|
||||
if (planFileNameRef.current !== null) {
|
||||
setPlanTodos(null);
|
||||
setPlanFileName(null);
|
||||
planFileNameRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the most recently modified file
|
||||
let latestFile = '';
|
||||
let latestMtime = 0;
|
||||
|
||||
for (const file of mdFiles) {
|
||||
const filePath = path.join(plansDir, file);
|
||||
const stats = await fs.promises.lstat(filePath);
|
||||
if (stats.isFile() && stats.mtimeMs > latestMtime) {
|
||||
latestMtime = stats.mtimeMs;
|
||||
latestFile = file;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
latestMtime > lastModifiedRef.current ||
|
||||
latestFile !== planFileNameRef.current
|
||||
) {
|
||||
const content = await fs.promises.readFile(
|
||||
path.join(plansDir, latestFile),
|
||||
'utf8',
|
||||
);
|
||||
const todos = parseMarkdownTodos(content);
|
||||
setPlanTodos(todos);
|
||||
setPlanFileName(latestFile);
|
||||
planFileNameRef.current = latestFile;
|
||||
lastModifiedRef.current = latestMtime;
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('File operation for updating plan failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
void updatePlan();
|
||||
|
||||
// Poll every 2 seconds for updates to the plan directory
|
||||
const interval = setInterval(() => {
|
||||
void updatePlan();
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [config]);
|
||||
|
||||
return { planTodos, planFileName };
|
||||
}
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
|
||||
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
import { WriteTodosTool } from '../tools/write-todos.js';
|
||||
import type { FileSystemService } from '../services/fileSystemService.js';
|
||||
import { StandardFileSystemService } from '../services/fileSystemService.js';
|
||||
import { logRipgrepFallback, logFlashFallback } from '../telemetry/loggers.js';
|
||||
@@ -220,6 +219,7 @@ import {
|
||||
import { McpClientManager } from '../tools/mcp-client-manager.js';
|
||||
import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { WriteTodosTool } from '../tools/write-todos.js';
|
||||
|
||||
export type { FileFilteringOptions };
|
||||
export {
|
||||
|
||||
@@ -161,6 +161,12 @@ ${planModeToolsList}
|
||||
## Plan Storage
|
||||
- Save your plans as Markdown (.md) files directly to: \`${plansDir}/\`
|
||||
- Use descriptive filenames: \`feature-name.md\` or \`bugfix-description.md\`
|
||||
- **Source of Truth:** The UI is based on these markdown files. To show progress in the CLI, you MUST update the plan file with the status markers below.
|
||||
- **Task Status Markers:** Use the following markers in your task lists to update the UI (note that these are case-sensitive):
|
||||
- \`- [ ] Task\` : Pending
|
||||
- \`- [/] Task\` : In Progress
|
||||
- \`- [x] Task\` : Completed
|
||||
- \`- [-] Task\` : Cancelled
|
||||
|
||||
## Workflow Phases
|
||||
|
||||
@@ -271,6 +277,11 @@ When requested to perform tasks like fixing bugs, adding features, refactoring,
|
||||
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards.${interactiveMode ? " If unsure about these commands, you can ask the user if they'd like you to run them and if so how to." : ''}
|
||||
6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction.
|
||||
|
||||
**Monitoring Progress:**
|
||||
- **Plans Directory:** \`${config.storage.getProjectTempPlansDir()}/\`
|
||||
- If a plan file exists in this directory, you MUST update it to reflect your progress using the \`write_file\` or \`replace\` tools.
|
||||
- Use markers (case-sensitive): \`- [ ]\` (Pending), \`- [/]\` (In Progress), \`- [x]\` (Completed), \`- [-]\` (Cancelled).
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are '${WRITE_FILE_TOOL_NAME}', '${EDIT_TOOL_NAME}' and '${SHELL_TOOL_NAME}'.
|
||||
|
||||
@@ -76,6 +76,7 @@ export * from './utils/tool-utils.js';
|
||||
export * from './utils/terminalSerializer.js';
|
||||
export * from './utils/systemEncoding.js';
|
||||
export * from './utils/textUtils.js';
|
||||
export * from './utils/planUtils.js';
|
||||
export * from './utils/formatters.js';
|
||||
export * from './utils/generateContentResponseUtilities.js';
|
||||
export * from './utils/filesearch/fileSearch.js';
|
||||
@@ -96,6 +97,7 @@ export * from './utils/secure-browser-launcher.js';
|
||||
export * from './utils/apiConversionUtils.js';
|
||||
export * from './utils/channel.js';
|
||||
export * from './utils/constants.js';
|
||||
export * from './utils/security.js';
|
||||
|
||||
// Export services
|
||||
export * from './services/fileDiscoveryService.js';
|
||||
|
||||
@@ -69,5 +69,4 @@ modes = ["plan"]
|
||||
toolName = "write_file"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
modes = ["plan"]
|
||||
argsPattern = "\"file_path\":\"[^\"]+/\\.gemini/tmp/[a-f0-9]{64}/plans/[a-zA-Z0-9_-]+\\.md\""
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseMarkdownTodos } from './planUtils.js';
|
||||
|
||||
describe('parseMarkdownTodos', () => {
|
||||
it('parses basic task list', () => {
|
||||
const markdown = `
|
||||
# Plan
|
||||
- [ ] Task 1
|
||||
- [x] Task 2
|
||||
- [/] Task 3
|
||||
- [-] Task 4
|
||||
`;
|
||||
const todos = parseMarkdownTodos(markdown);
|
||||
expect(todos).toEqual([
|
||||
{ description: 'Task 1', status: 'pending' },
|
||||
{ description: 'Task 2', status: 'completed' },
|
||||
{ description: 'Task 3', status: 'in_progress' },
|
||||
{ description: 'Task 4', status: 'cancelled' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses alternate in-progress markers', () => {
|
||||
const markdown = `
|
||||
- [>] Task 5
|
||||
- [ / ] Task 6
|
||||
`;
|
||||
const todos = parseMarkdownTodos(markdown);
|
||||
expect(todos).toEqual([
|
||||
{ description: 'Task 5', status: 'in_progress' },
|
||||
{ description: 'Task 6', status: 'in_progress' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles nested lists', () => {
|
||||
const markdown = `
|
||||
- [ ] Outer
|
||||
- [x] Inner
|
||||
`;
|
||||
const todos = parseMarkdownTodos(markdown);
|
||||
expect(todos).toEqual([
|
||||
{ description: 'Outer', status: 'pending' },
|
||||
{ description: 'Inner', status: 'completed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores non-task list items', () => {
|
||||
const markdown = `
|
||||
- Just a bullet
|
||||
- [ ] A task
|
||||
`;
|
||||
const todos = parseMarkdownTodos(markdown);
|
||||
expect(todos).toEqual([{ description: 'A task', status: 'pending' }]);
|
||||
});
|
||||
|
||||
it('is case-sensitive for completed marker', () => {
|
||||
const markdown = `
|
||||
- [x] lowercase
|
||||
- [X] uppercase
|
||||
`;
|
||||
const todos = parseMarkdownTodos(markdown);
|
||||
expect(todos).toEqual([
|
||||
{ description: 'lowercase', status: 'completed' },
|
||||
{ description: 'uppercase', status: 'pending' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { marked } from 'marked';
|
||||
import { type Todo, type TodoStatus } from '../tools/tools.js';
|
||||
|
||||
interface MarkedToken {
|
||||
type: string;
|
||||
raw: string;
|
||||
tokens?: MarkedToken[];
|
||||
items?: MarkedToken[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses markdown content to extract task lists and convert them to Todo items.
|
||||
*
|
||||
* Supported markers:
|
||||
* - [ ] -> pending
|
||||
* - [x], [X] -> completed
|
||||
* - [/], [>] -> in_progress
|
||||
* - [-] -> cancelled
|
||||
*
|
||||
* @param content The markdown content to parse.
|
||||
* @returns An array of Todo items.
|
||||
*/
|
||||
export function parseMarkdownTodos(content: string): Todo[] {
|
||||
const tokens = marked.lexer(content) as unknown as MarkedToken[];
|
||||
const todos: Todo[] = [];
|
||||
|
||||
const walk = (token: MarkedToken) => {
|
||||
if (token.type === 'list_item') {
|
||||
const raw = token.raw.trim();
|
||||
// Check for task marker manually since marked only supports [ ] and [x]
|
||||
// Support [ ], [x], [/], [>], [-] with optional spaces
|
||||
const taskMarkerMatch = raw.match(
|
||||
/^[-*+]\s+\[\s*([xX/\\>-]?)\s*\]\s+(.*)/s,
|
||||
);
|
||||
|
||||
if (taskMarkerMatch) {
|
||||
const marker = taskMarkerMatch[1];
|
||||
const description = taskMarkerMatch[2].split('\n')[0].trim(); // Take only the first line as description
|
||||
|
||||
let status: TodoStatus = 'pending';
|
||||
if (marker?.toLowerCase() === 'x') {
|
||||
status = 'completed';
|
||||
} else if (marker === '/' || marker === '>') {
|
||||
status = 'in_progress';
|
||||
} else if (marker === '-') {
|
||||
status = 'cancelled';
|
||||
} else if (marker === '' || marker === ' ') {
|
||||
status = 'pending';
|
||||
}
|
||||
|
||||
todos.push({
|
||||
description,
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (token.tokens) {
|
||||
token.tokens.forEach(walk);
|
||||
}
|
||||
if (token.items) {
|
||||
token.items.forEach(walk);
|
||||
}
|
||||
};
|
||||
|
||||
tokens.forEach(walk);
|
||||
return todos;
|
||||
}
|
||||
@@ -14,81 +14,132 @@ export interface SecurityCheckResult {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface SecurityCheckOptions {
|
||||
/**
|
||||
* The expected owner of the directory.
|
||||
* - 'root': Owned by root (uid 0) on POSIX.
|
||||
* - 'user': Owned by the current user.
|
||||
* Defaults to 'root'.
|
||||
*/
|
||||
owner?: 'root' | 'user';
|
||||
/**
|
||||
* Whether to allow symbolic links.
|
||||
* Defaults to false.
|
||||
*/
|
||||
allowSymlinks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies if a directory is secure (owned by root and not writable by others).
|
||||
* Verifies if a directory is secure.
|
||||
*
|
||||
* For 'root' owner (default):
|
||||
* - POSIX: Owned by root (uid 0) and not writable by group or others.
|
||||
* - Windows: ACLs checked to ensure standard users/groups don't have write access.
|
||||
*
|
||||
* For 'user' owner:
|
||||
* - POSIX: Owned by current user and has restrictive permissions (0700).
|
||||
* - Windows: Verified as a directory and not a symbolic link.
|
||||
*
|
||||
* @param dirPath The path to the directory to check.
|
||||
* @param options Security check options.
|
||||
* @returns A promise that resolves to a SecurityCheckResult.
|
||||
*/
|
||||
export async function isDirectorySecure(
|
||||
dirPath: string,
|
||||
options: SecurityCheckOptions = {},
|
||||
): Promise<SecurityCheckResult> {
|
||||
const { owner = 'root', allowSymlinks = false } = options;
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(dirPath);
|
||||
const stats = await fs.lstat(dirPath);
|
||||
|
||||
if (!allowSymlinks && stats.isSymbolicLink()) {
|
||||
return { secure: false, reason: 'Directory is a symbolic link' };
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return { secure: false, reason: 'Not a directory' };
|
||||
}
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
try {
|
||||
// Check ACLs using PowerShell to ensure standard users don't have write access
|
||||
const escapedPath = dirPath.replace(/'/g, "''");
|
||||
const script = `
|
||||
$path = '${escapedPath}';
|
||||
$acl = Get-Acl -LiteralPath $path;
|
||||
$rules = $acl.Access | Where-Object {
|
||||
$_.AccessControlType -eq 'Allow' -and
|
||||
(($_.FileSystemRights -match 'Write') -or ($_.FileSystemRights -match 'Modify') -or ($_.FileSystemRights -match 'FullControl'))
|
||||
};
|
||||
$insecureIdentity = $rules | Where-Object {
|
||||
$_.IdentityReference.Value -match 'Users' -or $_.IdentityReference.Value -eq 'Everyone'
|
||||
} | Select-Object -ExpandProperty IdentityReference;
|
||||
Write-Output ($insecureIdentity -join ', ');
|
||||
`;
|
||||
if (owner === 'root') {
|
||||
try {
|
||||
// Check ACLs using PowerShell to ensure standard users don't have write access
|
||||
const escapedPath = dirPath.replace(/'/g, "''");
|
||||
const script = `
|
||||
$path = '${escapedPath}';
|
||||
$acl = Get-Acl -LiteralPath $path;
|
||||
$rules = $acl.Access | Where-Object {
|
||||
$_.AccessControlType -eq 'Allow' -and
|
||||
(($_.FileSystemRights -match 'Write') -or ($_.FileSystemRights -match 'Modify') -or ($_.FileSystemRights -match 'FullControl'))
|
||||
};
|
||||
$insecureIdentity = $rules | Where-Object {
|
||||
$_.IdentityReference.Value -match 'Users' -or $_.IdentityReference.Value -eq 'Everyone'
|
||||
} | Select-Object -ExpandProperty IdentityReference;
|
||||
Write-Output ($insecureIdentity -join ', ');
|
||||
`;
|
||||
|
||||
const { stdout } = await spawnAsync('powershell', [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
script,
|
||||
]);
|
||||
const { stdout } = await spawnAsync('powershell', [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
script,
|
||||
]);
|
||||
|
||||
const insecureGroups = stdout.trim();
|
||||
if (insecureGroups) {
|
||||
const insecureGroups = stdout.trim();
|
||||
if (insecureGroups) {
|
||||
return {
|
||||
secure: false,
|
||||
reason: `Directory '${dirPath}' is insecure. The following user groups have write permissions: ${insecureGroups}. To fix this, remove Write and Modify permissions for these groups from the directory's ACLs.`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
secure: false,
|
||||
reason: `Directory '${dirPath}' is insecure. The following user groups have write permissions: ${insecureGroups}. To fix this, remove Write and Modify permissions for these groups from the directory's ACLs.`,
|
||||
reason: `A security check for the system policy directory '${dirPath}' failed and could not be completed. Please file a bug report. Original error: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { secure: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
secure: false,
|
||||
reason: `A security check for the system policy directory '${dirPath}' failed and could not be completed. Please file a bug report. Original error: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
return { secure: true };
|
||||
}
|
||||
|
||||
// POSIX checks
|
||||
// Check ownership: must be root (uid 0)
|
||||
if (stats.uid !== 0) {
|
||||
return {
|
||||
secure: false,
|
||||
reason: `Directory '${dirPath}' is not owned by root (uid 0). Current uid: ${stats.uid}. To fix this, run: sudo chown root:root "${dirPath}"`,
|
||||
};
|
||||
}
|
||||
if (owner === 'root') {
|
||||
// Check ownership: must be root (uid 0)
|
||||
if (stats.uid !== 0) {
|
||||
return {
|
||||
secure: false,
|
||||
reason: `Directory '${dirPath}' is not owned by root (uid 0). Current uid: ${stats.uid}. To fix this, run: sudo chown root:root "${dirPath}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check permissions: not writable by group (S_IWGRP) or others (S_IWOTH)
|
||||
const mode = stats.mode;
|
||||
if ((mode & (constants.S_IWGRP | constants.S_IWOTH)) !== 0) {
|
||||
return {
|
||||
secure: false,
|
||||
reason: `Directory '${dirPath}' is writable by group or others (mode: ${mode.toString(
|
||||
8,
|
||||
)}). To fix this, run: sudo chmod g-w,o-w "${dirPath}"`,
|
||||
};
|
||||
// Check permissions: not writable by group (S_IWGRP) or others (S_IWOTH)
|
||||
const mode = stats.mode;
|
||||
if ((mode & (constants.S_IWGRP | constants.S_IWOTH)) !== 0) {
|
||||
return {
|
||||
secure: false,
|
||||
reason: `Directory '${dirPath}' is writable by group or others (mode: ${mode.toString(
|
||||
8,
|
||||
)}). To fix this, run: sudo chmod g-w,o-w "${dirPath}"`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const userInfo = os.userInfo();
|
||||
if (stats.uid !== userInfo.uid) {
|
||||
return {
|
||||
secure: false,
|
||||
reason: `Directory is not owned by the current user (uid ${userInfo.uid})`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for restrictive permissions (0700)
|
||||
const mode = stats.mode & 0o777;
|
||||
if (mode !== 0o700) {
|
||||
return {
|
||||
secure: false,
|
||||
reason: `Directory has insecure permissions: ${mode.toString(8)} (expected 0700)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { secure: true };
|
||||
|
||||
Reference in New Issue
Block a user