Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/calm-shells-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openwiki": patch
---

fix: restrict shell execution during repository documentation runs
50 changes: 45 additions & 5 deletions src/agent/docs-only-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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<ExecuteResponse> {
if (
Expand All @@ -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);
}

Expand Down Expand Up @@ -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),
);
}
27 changes: 16 additions & 11 deletions src/agent/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions test/docs-only-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
33 changes: 33 additions & 0 deletions test/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down