diff --git a/.changeset/calm-shells-guard.md b/.changeset/calm-shells-guard.md new file mode 100644 index 00000000..982a8376 --- /dev/null +++ b/.changeset/calm-shells-guard.md @@ -0,0 +1,5 @@ +--- +"openwiki": patch +--- + +fix: restrict shell execution during repository documentation runs diff --git a/src/agent/docs-only-backend.ts b/src/agent/docs-only-backend.ts index 68b1a2c8..f2280767 100644 --- a/src/agent/docs-only-backend.ts +++ b/src/agent/docs-only-backend.ts @@ -68,6 +68,20 @@ const allowedIgnoredShellCommands = [ /^rm\s+-f\s+(?:\.\/)?openwiki\/_plan\.md$/u, ]; +/** + * Read-only shell commands that remain available during repository init/update. + * + * Repository docs-only runs cannot safely admit arbitrary shell syntax: even a + * command that normally reads can be combined with redirection, substitution, + * aliases, or a second process to mutate paths outside `openwiki/`. Keep this + * list smaller than the `.openwikiignore` maintenance allowlist; deterministic + * host-side cleanup removes `_plan.md`, so the agent itself needs no shell write. + */ +const allowedDocsOnlyShellCommands = [ + /^pwd$/u, + /^git\s+(?:--no-pager\s+)?rev-parse\s+HEAD$/u, +]; + /** * Filesystem/shell backend that enforces OpenWiki's access boundaries for the * doc-generation agent. @@ -79,8 +93,9 @@ const allowedIgnoredShellCommands = [ * denied with an error; discovery tools (`ls`/`glob`/`grep`) silently drop * ignored entries; uploads/downloads reject ignored paths; and shell * `execute` is restricted to a small allowlist while any rule is active. - * 2. Docs-only confinement (`docsOnly`): in repository mode, writes are limited - * to the `openwiki/` tree via {@link isOpenWikiDocsPath}. + * 2. Docs-only confinement (`docsOnly`): in repository mode, filesystem writes + * are limited to the `openwiki/` tree via {@link isOpenWikiDocsPath}, and + * shell `execute` is limited to a read-only allowlist. * * Both are security boundaries against an agent that may be prompt-injected via * untrusted repository content, so path checks canonicalize before matching. @@ -319,9 +334,10 @@ export class OpenWikiLocalShellBackend extends LocalShellBackend { /** * Run a shell command. While any `.openwikiignore` rule is active, only the - * {@link allowedIgnoredShellCommands} allowlist may run; anything else is - * refused (exit code 1) with guidance to use the gated filesystem tools, - * since arbitrary shell cannot be proven not to read an ignored path. + * {@link allowedIgnoredShellCommands} allowlist may run. Repository docs-only + * runs independently use {@link allowedDocsOnlyShellCommands}, preventing + * shell redirection or another mutation primitive from bypassing the path + * checks enforced by the structured filesystem methods. */ override async execute(command: string): Promise { if ( @@ -335,6 +351,19 @@ export class OpenWikiLocalShellBackend extends LocalShellBackend { }; } + if ( + this.docsOnly && + this.outputMode === "repository" && + !isAllowedDocsOnlyShellCommand(command) + ) { + return { + exitCode: 1, + output: + "Shell execute is restricted during OpenWiki repository init/update runs. Use the filesystem tools for repository inspection and documentation writes.", + truncated: false, + }; + } + return super.execute(command); } @@ -430,3 +459,14 @@ function isAllowedShellCommandWithIgnore(command: string): boolean { allowedCommand.test(trimmedCommand), ); } + +/** + * Whether a shell command is on the repository docs-only read-only allowlist. + */ +function isAllowedDocsOnlyShellCommand(command: string): boolean { + const trimmedCommand = command.trim(); + + return allowedDocsOnlyShellCommands.some((allowedCommand) => + allowedCommand.test(trimmedCommand), + ); +} diff --git a/src/agent/prompt.ts b/src/agent/prompt.ts index b58bc10a..c936c0d6 100644 --- a/src/agent/prompt.ts +++ b/src/agent/prompt.ts @@ -23,21 +23,22 @@ export function createSystemPrompt( const output = getOutputPromptConfig(outputMode); const languageInstructions = createLanguageInstructions(language); const ignoreActive = openWikiIgnore?.isActive === true; + const shellRestricted = + ignoreActive || (outputMode === "repository" && command !== "chat"); - // When .openwikiignore is active the execute allowlist refuses shell-based - // discovery, so the prompt must steer to the file tools and the provided git - // summary instead of git/rg. When inactive these keep today's wording exactly, - // so the common no-ignore run is unchanged. - const gitHistoryHint = ignoreActive + // Restricted runs refuse shell-based discovery, so the prompt must steer to + // file tools and the provided git summary instead of suggesting git/rg calls + // that the backend will reject. Chat and local-wiki runs retain shell access. + const gitHistoryHint = shellRestricted ? "Use the provided git summary for repository history. " : "Use git through shell execute when it provides useful history. "; - const discoveryHint = ignoreActive + const discoveryHint = shellRestricted ? "- Do not call glob with **/* from the root. Use targeted ls, glob, and grep by directory and extension, skipping .git, node_modules, dist, build, cache directories, and existing generated wiki output." : "- Do not call glob with **/* from the root. Use targeted discovery by directory and extension. Prefer shell commands like rg --files with excludes for .git, node_modules, dist, build, cache directories, and existing generated wiki output."; - const gitDiscipline = ignoreActive + const gitDiscipline = shellRestricted ? `Git discipline: -- A filtered git summary of repository history is provided in your context. Use it to explain why code exists, not just what it does, focusing on recent, high-signal changes. -- The summary already excludes .openwikiignore paths. Do not run git or other shell commands to reconstruct history; shell discovery is unavailable while .openwikiignore is active.` +- A git summary of repository history is provided in your context. Use it to explain why code exists, not just what it does, focusing on recent, high-signal changes. +- Do not run git or other shell commands to reconstruct history; shell discovery is unavailable for this run.` : `Git discipline: - Use git heavily where it helps explain why code exists, not just what code exists. - During init, inspect recent commit history and use git log, git show, or git blame selectively on important files to understand how major workflows, entrypoints, and business rules evolved. @@ -57,7 +58,11 @@ Use only the tools available to you. Prefer built-in filesystem discovery tools Run discipline: - ${output.filesystemRootInstruction} - Never pass host absolute paths like /Users/... to filesystem tools; that creates nested paths inside the repo instead of touching the intended file. -- Shell execute commands run on the host. If you use execute, run commands from the current runtime root unless a source-specific instruction explicitly tells you to inspect a connector raw file or configured local repository path. +- ${ + shellRestricted + ? "Shell execute is restricted to a few read-only maintenance commands. Use the structured filesystem tools for repository inspection and documentation writes." + : "Shell execute commands run on the host. If you use execute, run commands from the current runtime root unless a source-specific instruction explicitly tells you to inspect a connector raw file or configured local repository path." + } - Do not exhaustively read every file. For a local knowledge wiki, inspect the existing wiki structure and only the relevant connector evidence or configured local repository paths. For an explicit repository source, inspect the repository tree, package/config files, README-style files, entrypoints, routing files, database/schema files, and representative files for each major domain. ${discoveryHint} - Prefer grep/glob and short targeted reads over full-file reads when files are large. @@ -203,7 +208,7 @@ Section quality rules: Required documentation structure: - ${output.quickstartPath} must be the entrypoint. - ${output.quickstartPath} must include a high-level overview and links to every major section. -- When writing required documentation with filesystem tools or narrow shell execute, use ${output.writePathExample}. +- When writing required documentation with filesystem tools, use ${output.writePathExample}. - ${output.sectionDirectoryInstruction} - Each section directory should contain focused Markdown pages; if a directory would contain only one short page, prefer a broader page or a heading in ${output.quickstartPath}. - Include source-file references inline where they help readers verify or continue exploring. diff --git a/test/docs-only-backend.test.ts b/test/docs-only-backend.test.ts index 17f4db0a..83287f3d 100644 --- a/test/docs-only-backend.test.ts +++ b/test/docs-only-backend.test.ts @@ -65,6 +65,22 @@ describe("OpenWikiLocalShellBackend", () => { await expect( readFile(path.join(rootDir, "AGENTS.md"), "utf8"), ).rejects.toThrow(); + + for (const command of [ + "echo bad > AGENTS.md", + "pwd && echo bad > AGENTS.md", + "git rev-parse HEAD > AGENTS.md", + ]) { + const shellWrite = await backend.execute(command); + expect(shellWrite.exitCode).toBe(1); + expect(shellWrite.output).toContain("repository init/update"); + } + await expect( + readFile(path.join(rootDir, "AGENTS.md"), "utf8"), + ).rejects.toThrow(); + + const allowedReadOnlyCommand = await backend.execute("pwd"); + expect(allowedReadOnlyCommand.exitCode).toBe(0); }); test("allows local-wiki init/update writes at the wiki virtual root", async () => { @@ -83,6 +99,10 @@ describe("OpenWikiLocalShellBackend", () => { await expect( readFile(path.join(rootDir, "quickstart.md"), "utf8"), ).resolves.toBe("updated"); + + const execute = await backend.execute("echo shell-available"); + expect(execute.exitCode).toBe(0); + expect(execute.output).toContain("shell-available"); }); test("keeps chat-mode style backends unrestricted when docsOnly is false", async () => { diff --git a/test/prompt.test.ts b/test/prompt.test.ts index 16b2eabf..2a9a4415 100644 --- a/test/prompt.test.ts +++ b/test/prompt.test.ts @@ -116,6 +116,39 @@ describe("createSystemPrompt filesystem path guidance", () => { }); }); +describe("createSystemPrompt shell guidance", () => { + for (const command of ["init", "update"] as const) { + test(`repository ${command}: directs the agent away from restricted shell discovery and writes`, () => { + const prompt = createSystemPrompt(command, "repository"); + + expect(prompt).toContain( + "Shell execute is restricted to a few read-only maintenance commands", + ); + expect(prompt).toContain( + "Use the structured filesystem tools for repository inspection and documentation writes", + ); + expect(prompt).not.toContain("Use git through shell execute"); + expect(prompt).not.toContain("narrow shell execute"); + }); + } + + test("repository chat retains shell discovery", () => { + const prompt = createSystemPrompt("chat", "repository"); + + expect(prompt).toContain("Use git through shell execute"); + expect(prompt).toContain("Shell execute commands run on the host"); + }); + + test("local-wiki init retains shell access for connector raw files", () => { + const prompt = createSystemPrompt("init", "local-wiki"); + + expect(prompt).toContain("Shell execute commands run on the host"); + expect(prompt).not.toContain( + "Shell execute is restricted to a few read-only maintenance commands", + ); + }); +}); + /** * The deterministic post-run pass repairs missing or invalid front matter and * tags the page `openwiki_generated`. The prompt must tell the agent that code