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
5 changes: 4 additions & 1 deletion .github/workflows/shell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -342,8 +342,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
if: matrix.test == 'opencode-claude-auth-refresh-hardening'
if: matrix.test == 'opencode-claude-auth-refresh-hardening' || matrix.test == 'kimaki-managed-plugin-rig'
with:
node-version: 24
- name: Install supported OpenCode dispatch fixture
if: matrix.test == 'kimaki-managed-plugin-rig'
run: npm install --global opencode-ai@1.18.20
- name: Run tests/${{ matrix.test }}.sh
run: bash tests/${{ matrix.test }}.sh
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,8 @@ Kimaki is the Discord surface for OpenCode. Managed installs replace Kimaki's ge

Kimaki-specific OpenCode plugins are synced into Kimaki's config directory and restored across package updates. The managed-plugin rig verifies that contract:

Managed context also enforces coding-route ownership independently of prompt text. The [dispatch contract and guard documentation](docs/kimaki-managed-context.md) explains the retained-array boundary, lifecycle checks, and real OpenCode loopback verification.

Inbound events queue independently of a runtime. The queue intentionally has no Kimaki connector: the supported Kimaki CLI proves outbound `send --channel <id> --prompt <text>`, but not inbound event injection or a durable conversation-to-runtime mapping. A connector can be added only after that contract exists; missing mappings fail closed.

The optional signed Slack adapter is configured through the inbound adapter config filter or constant with a signing secret, runtime ID, and explicit `allowed_team_ids` plus `allowed_channel_ids` allowlists.
Expand Down
236 changes: 229 additions & 7 deletions bridges/kimaki/plugins/dm-context-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,39 @@ For Kimaki bridge failures, inspect \$HOME/.kimaki/kimaki.log. The log is reset
`;

const fleetContextFilter: Plugin = async () => {
const systems = new Map<string, Set<string[]>>();
const parameters = new Map<string, { options: Record<string, any> }>();
const requestKey = (input: any) => `${input.sessionID ?? ""}:${input.model?.providerID ?? ""}:${input.model?.id ?? ""}`;
const parameterKey = (input: any) => `${requestKey(input)}:${input.agent ?? ""}:${input.message?.id ?? ""}`;
const checkSystems = (key: string) => {
for (const blocks of systems.get(key) ?? []) {
for (const block of blocks) assertManagedPrompt(block);
}
};
return {
dispose: async () => { systems.clear(); parameters.clear(); },
event: async ({ event }) => {
const properties = event.properties as any;
const idle = event.type === "session.status" && properties.status?.type === "idle";
if (!idle && !["session.idle", "session.error", "session.deleted"].includes(event.type)) return;
const sessionID = properties.sessionID ?? properties.info?.id;
if (!sessionID) return;
for (const key of systems.keys()) if (key.startsWith(`${sessionID}:`)) systems.delete(key);
for (const key of parameters.keys()) if (key.startsWith(`${sessionID}:`)) parameters.delete(key);
},
// Replace Kimaki's generic CLI/orchestration prompt with managed guidance.
"experimental.chat.system.transform": async (_input, output) => {
output.system = output.system.map((block) => {
return replaceKimakiSystemPrompt(block);
});
"experimental.chat.system.transform": async (input, output) => {
// OpenCode retains this array; replacing the wrapper property does not
// change the instructions its request builder subsequently serializes.
for (let i = 0; i < output.system.length; i++) {
output.system[i] = replaceKimakiSystemPrompt(output.system[i]);
}
if (input.sessionID) {
const key = requestKey(input);
const pending = systems.get(key) ?? new Set<string[]>();
pending.add(output.system);
systems.set(key, pending);
}
},

// Kimaki passes its generated Discord prompt through promptAsync's per-call
Expand All @@ -56,6 +83,10 @@ const fleetContextFilter: Plugin = async () => {
// experimental hook shape.
"experimental.chat.messages.transform": async (_input, output) => {
for (const message of output.messages) {
const info = message.info as any;
if (typeof info.system === "string") {
info.system = replaceKimakiSystemPrompt(info.system);
}
for (const part of message.parts) {
if (part.type !== "text") {
continue;
Expand All @@ -64,13 +95,19 @@ const fleetContextFilter: Plugin = async () => {
if (typeof text !== "string") {
continue;
}
if (info.role && !(part as any).synthetic && info.role !== "system" && info.role !== "developer") {
continue;
}
(part as any).text = replaceKimakiSystemPrompt(text);
}
}
},

// Filter out Kimaki's MEMORY.md injection and time-gap MEMORY.md reminders.
"chat.message": async (_input, output) => {
if (typeof output.message?.system === "string") {
output.message.system = replaceKimakiSystemPrompt(output.message.system);
}
// Walk backwards so splice indices stay valid.
for (let i = output.parts.length - 1; i >= 0; i--) {
const part = output.parts[i];
Expand All @@ -96,13 +133,54 @@ const fleetContextFilter: Plugin = async () => {

}
},

// OAuth request builders carry instructions in provider options rather
// than system messages. Normalize that independently of the earlier hook.
"chat.params": async (input, output) => {
checkSystems(requestKey(input));
if (typeof input.message?.system === "string") {
input.message.system = replaceKimakiSystemPrompt(input.message.system);
}
if (typeof output.options.instructions === "string") {
output.options.instructions = replaceKimakiSystemPrompt(output.options.instructions);
}
if (input.sessionID) parameters.set(parameterKey(input), output);
},

// Headers run after every chat.params plugin. A later plugin that restores
// generic guidance must stop dispatch, not make an earlier PASS misleading.
"chat.headers": async (input) => {
const key = parameterKey(input);
try {
checkSystems(requestKey(input));
const instructions = parameters.get(key)?.options.instructions;
if (typeof instructions === "string") assertManagedPrompt(instructions);
} finally {
parameters.delete(key);
}
},

"experimental.session.compacting": async (_input, output) => {
for (let i = 0; i < output.context.length; i++) {
output.context[i] = replaceKimakiSystemPrompt(output.context[i]);
}
if (typeof output.prompt === "string") {
output.prompt = replaceKimakiSystemPrompt(output.prompt);
}
},

"tool.execute.before": async (input, output) => {
if (input.tool === "bash" && typeof output.args?.command === "string") {
assertManagedKimakiCommand(output.args.command);
}
},
};
};

// Replace only positively identified Kimaki prompt text. Both transforms use
// this helper because message transforms also receive unrelated text.
function replaceKimakiSystemPrompt(block: string): string {
const kimakiStart = kimakiSystemPromptStart(block);
const kimakiStart = unmanagedKimakiSystemPromptStart(block);
if (kimakiStart === -1) {
return block;
}
Expand All @@ -113,7 +191,11 @@ function replaceKimakiSystemPrompt(block: string): string {
return [prefix, MANAGED_KIMAKI_SYSTEM_PROMPT].filter(Boolean).join("\n\n");
}

function kimakiSystemPromptStart(block: string): number {
function unmanagedKimakiSystemPromptStart(block: string): number {
// Canonical bridge text may precede other components' instructions. Mask it
// without changing offsets so those unrelated suffixes survive normalization.
const managed = MANAGED_KIMAKI_SYSTEM_PROMPT.trim();
const candidate = block.split(managed).join(" ".repeat(managed.length));
const markers = [
"## Kimaki Discord Bridge",
"The user is reading your messages from inside Discord, via kimaki.dev",
Expand All @@ -123,7 +205,7 @@ function kimakiSystemPromptStart(block: string): number {
];

return markers.reduce((earliest, marker) => {
const index = block.indexOf(marker);
const index = candidate.indexOf(marker);
if (index === -1) {
return earliest;
}
Expand All @@ -132,3 +214,143 @@ function kimakiSystemPromptStart(block: string): number {
}

export default fleetContextFilter;

// This is an ownership guard for ordinary CLI launch forms, not a sandbox for
// arbitrary code. Quoted command data (printf, node -e, test fixtures) is inert;
// shell -c/eval launches are inspected as commands instead.
function assertManagedKimakiCommand(command: string, depth = 0): void {
if (!/kimaki/i.test(command)) return;
if (depth > 8) throw new Error("Managed command nesting exceeds the ownership guard limit.");
for (let words of shellCommands(command)) {
while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words = words.slice(1);
let executable = words[0]?.split("/").pop();
if (executable === "env") {
words = words.slice(1);
while (words.length && (words[0].startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
if (["-S", "--split-string"].includes(words[0])) {
assertManagedKimakiCommand(words[1] ?? "", depth + 1);
words = words.slice(2);
continue;
}
if (words[0].startsWith("--split-string=")) {
assertManagedKimakiCommand(words[0].slice("--split-string=".length), depth + 1);
}
words = words.slice(["-u", "--unset", "-C", "--chdir"].includes(words[0]) ? 2 : 1);
}
assertManagedKimakiCommand(words.map(shellQuote).join(" "), depth + 1);
continue;
}
if (["sh", "bash", "zsh", "dash", "fish"].includes(executable ?? "")) {
const index = words.findIndex(word => /^-[^-]*c/.test(word) || word === "--command");
if (index !== -1 && words[index + 1]) assertManagedKimakiCommand(words[index + 1], depth + 1);
continue;
}
if (executable === "eval") {
assertManagedKimakiCommand(words.slice(1).join(" "), depth + 1);
continue;
}
if (["command", "exec", "nohup", "time", "sudo", "timeout"].includes(executable ?? "")) {
const wrapper = executable;
words = words.slice(1);
while (words[0]?.startsWith("-")) {
words = words.slice(["-u", "-g", "--user", "--group", "-k", "--kill-after"].includes(words[0]) ? 2 : 1);
}
if (wrapper === "timeout") words = words.slice(1);
assertManagedKimakiCommand(words.map(shellQuote).join(" "), depth + 1);
continue;
}
if (["npm", "pnpm", "yarn", "bun"].includes(executable ?? "") && ["exec", "dlx", "x"].includes(words[1])) {
words = words.slice(1);
executable = "npx";
}
if (["npx", "bunx"].includes(executable ?? "")) {
words = words.slice(1);
while (words[0]?.startsWith("-")) {
if (["-c", "--call"].includes(words[0])) {
assertManagedKimakiCommand(words[1] ?? "", depth + 1);
words = [];
break;
}
words = words.slice(["-p", "--package", "--shell"].includes(words[0]) ? 2 : 1);
}
executable = words[0]?.split("/").pop();
}
if (executable === "node" && /(?:^|\/)kimaki(?:\/|$)/.test(words[1] ?? "")) {
words = words.slice(1);
executable = "kimaki";
}
if (executable !== "kimaki") continue;
if (["--help", "-h"].includes(words[2]) || (words[1] === "project" && ["--help", "-h"].includes(words[3]))) continue;
const deniedSend = words[1] === "send" && (
!words.includes("--notify-only") || words.some(word => /^--(?:worktree|cwd|thread|session)(?:=|$)/.test(word))
);
const deniedProject = words[1] === "project" && ["add", "create"].includes(words[2]);
if (deniedSend || deniedProject) {
throw new Error("Managed Kimaki ownership guard: coding sessions, projects, and worktrees belong to the composed managed runtime. Use Homeboy's tracked workspace/task route; Kimaki remains the Discord bridge. Notification-only sends, uploads, and explicit archive operations remain available.");
}
}
}

function assertManagedPrompt(block: string): void {
if (unmanagedKimakiSystemPromptStart(block) !== -1) {
throw new Error("Managed Kimaki dispatch guard: generic instruction context was reintroduced after filtering. Dispatch refused; inspect active plugin ordering and request assembly. No prompt contents were logged.");
}
}

function shellQuote(word: string): string {
return "'" + word.replace(/'/g, "'\\''") + "'";
}

function shellCommands(command: string): string[][] {
const commands: string[][] = [];
let words: string[] = [], word = "", quote = "", escaped = false;
const flushWord = () => { if (word) words.push(word); word = ""; };
const flushCommand = () => { flushWord(); if (words.length) commands.push(words); words = []; };
for (let i = 0; i < command.length; i++) {
const char = command[i];
if (escaped) { word += char; escaped = false; continue; }
if (char === "\\" && quote !== "'") { escaped = true; continue; }
if (char === "`" && quote !== "'") {
let end = i + 1;
for (; end < command.length; end++) {
if (command[end] === "\\") { end++; continue; }
if (command[end] === "`") break;
}
if (end === command.length) throw new Error("Managed command ownership check requires complete command substitution.");
commands.push(...shellCommands(command.slice(i + 1, end)));
word += "substitution";
i = end;
continue;
}
if (char === "$" && command[i + 1] === "(" && quote !== "'") {
let nesting = 1, innerQuote = "", innerEscape = false, end = i + 2;
for (; end < command.length; end++) {
const inner = command[end];
if (innerEscape) { innerEscape = false; continue; }
if (inner === "\\" && innerQuote !== "'") { innerEscape = true; continue; }
if (innerQuote) { if (inner === innerQuote) innerQuote = ""; continue; }
if (inner === "'" || inner === '"') { innerQuote = inner; continue; }
if (inner === "(") nesting++;
if (inner === ")" && --nesting === 0) break;
}
if (nesting !== 0) throw new Error("Managed command ownership check requires complete command substitution.");
commands.push(...shellCommands(command.slice(i + 2, end)));
word += "substitution";
i = end;
continue;
}
if (quote) {
if (char === quote) quote = "";
else word += char;
continue;
}
if (char === "'" || char === '"') { quote = char; continue; }
if (char === "#" && !word) { while (i < command.length && command[i] !== "\n") i++; flushCommand(); continue; }
if (";|&\n".includes(char)) { flushCommand(); continue; }
if (/\s/.test(char)) { flushWord(); continue; }
word += char;
}
if (quote || escaped) throw new Error("Managed command ownership check requires complete shell quoting.");
flushCommand();
return commands;
}
72 changes: 72 additions & 0 deletions docs/kimaki-managed-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Managed Kimaki context and ownership

Kimaki supplies the Discord bridge. Composed agent guidance owns coding runtime,
workspace, orchestration, and evidence policy. The managed context filter replaces
positively identified Kimaki instructions with the compact bridge contract.

## Request assembly contract

OpenCode retains the system array passed to
`experimental.chat.system.transform`. Its request builder does not necessarily
use a reassigned `output.system` property. The filter mutates the array in place;
otherwise an isolated transform test can pass while the original instructions
are serialized into the provider request (issue #647).

The same replacement handles per-call `message.system`, resumed message metadata,
synthetic instruction parts, compaction context, and provider `options.instructions`.
Ordinary user/assistant text is preserved. Composed instruction prefixes and
unrelated system blocks remain unchanged.

The filter keeps request references through parameter assembly and checks them
again in `chat.headers`, after all parameter hooks. Later reinjection into a
retained system array or provider instructions refuses dispatch with a specific
diagnostic. Errors contain no raw prompt or credential values. This check covers
the supported OpenCode hook boundary; it is not a claim about arbitrary transport
code rewriting the request after those hooks.

## Independent command ownership guard

`tool.execute.before` rejects ordinary Bash launches of Kimaki coding-session
spawning and project/worktree creation. It covers direct and absolute executable
paths, common shell/environment/package-manager wrappers, compound commands, and
command substitution. Shell help, read-only session/project inspection,
notification-only sends, uploads, and archive commands remain available.

Notification sends targeting existing sessions/threads, worktrees, or custom
working directories are rejected: those routes may invoke a coding session.
Quoted command examples printed as data remain intact.

This is an operational ownership guard, **not an arbitrary-code sandbox**. Dynamic
programs can invoke subprocesses outside these recognized CLI launch forms. The
managed runtime's own workspace admission and permission boundaries remain
necessary. Operators change policy through managed configuration; an instruction
in the conversation is not an override.

## Verification

```bash
node tests/kimaki-dispatch-contract.mjs
node tests/kimaki-live-dispatch.mjs
bash tests/kimaki-managed-plugin-rig.sh
```

The dispatch-contract test retains the caller's array and captures serialized
HTTP instructions/system messages. It also exercises lifecycle normalization,
later-hook reinjection, command refusal, and preservation of ordinary data.

The live-dispatch test runs the installed OpenCode executable in an isolated
temporary project with a loopback-only model fixture. It checks actual outgoing
provider payloads for fresh/resumed runs, then returns a forbidden tool call and
verifies refusal before a harmless sentinel executable can run. It uses no model
account and does not restart the running bot. The current live probe exercises
the OpenAI-compatible HTTP path; OAuth option and reinjection behavior are also
covered by the dispatch-contract test, not claimed as a paid live OAuth probe.

The native probe also loads a later fixture plugin that reinjects generic system
or provider-option instructions. Both cases must emit the dispatch diagnostic
with zero requests reaching the model fixture.

The managed rig runs both checks when OpenCode is installed. If it is absent, the
rig explicitly reports the native dispatch proof as unverified instead of
equating offline filter success with live coverage. Raw upstream prompt snapshot
drift is separate from final-dispatch leakage (issue #606).
Loading
Loading