mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Live end-to-end verification (opencode 1.17.8 + grok-build-0.1) of the WHOLE
integration, then fixes for what it surfaced:
1) Intake draft card (FUNCTIONAL): opencode's synchronous serve reply
(POST /session/:id/message) returns only [step-start, text, step-finish] — it
does NOT include tool-call parts, so the driver could never extract the
propose_draft draft. intake-tools.js now POSTs the draft straight to the
prompter-live relay (/api/prompter/live/{session}/events, the same endpoint
the driver's relay sink uses), so the panel renders the card regardless.
Verified live: grok calls propose_draft -> the relay receives the draft.
2) Correct misattributed opencode "bugs" (DOCS): earlier comments asserted as
general opencode behavior that a provider.xai block / npm override / config
plugin:-array "break" registration. Re-testing showed those were artifacts of
a PROJECT-level .opencode/opencode.json; from the GLOBAL config (which
opencode_config writes) the built-in provider, model resolution, the plugin
array AND the auto-discovery dir all work, and MCP gateway verbs register
(delivery agents verified). Reframed the comments as design choices (built-in
provider + XAI_API_KEY env + plugins baked in the auto-discovery dir with
named exports) and dropped the false claims.
3) Reasoning --variant: passing it does not error, but whether opencode applies a
named reasoning variant to grok-build-0.1 (no provider-defined variants) is
UNVERIFIED — comment softened from a "~54% cut" claim to best-effort,
measure-on-NAS.
Verified live this session: one-shot delivery (model + MCP verbs + plugins +
hooks), secretary tools (read_company_state + submit_directive -> backend with
token), intake draft (relay), grok built-in-provider tool-calling. Remaining
NAS-only: full container assembly (SDK :9000 startup, entrypoint hooks, 429
parking) + the --variant cost measurement. Gate green (ruff/mypy + 51 tests;
node --check the plugins).
192 lines
8.5 KiB
JavaScript
192 lines
8.5 KiB
JavaScript
// opencode plugin — command guard / secret-scrub for RoboCo Grok agents.
|
|
//
|
|
// Ports the security-critical deny rules from docker/scripts/bash-guard-hook.sh
|
|
// (the Claude Code PreToolUse guard) to opencode's `tool.execute.before` hook.
|
|
// Those rules are Claude Code hooks and do NOT transfer to the opencode runtime,
|
|
// so a Grok agent would otherwise run bash unguarded — this restores parity.
|
|
//
|
|
// Mechanism (confirmed by opencode's own env-protection plugin example):
|
|
// throwing inside `tool.execute.before` denies the tool call. For `bash` the
|
|
// command is `output.args.command`; for `read`/`edit` the path is
|
|
// `output.args.filePath`.
|
|
//
|
|
// Loaded via the generated opencode.json `plugin:` array (see
|
|
// roboco.llm.providers.opencode_config). The agent's bash permission can also
|
|
// be set to "deny"/"ask" via ROBOCO_GROK_BASH_PERMISSION as a second gate.
|
|
//
|
|
// STATUS: unvalidated against a live opencode runtime. Confirm it actually
|
|
// fires in the live E2E spawn before pointing a Grok dev-agent at a real repo.
|
|
// Deny-on-match is fail-closed: a false positive blocks a legitimate command
|
|
// (annoying, safe) rather than letting a dangerous one through.
|
|
|
|
const CREDENTIAL_FILE =
|
|
/(\.git\/config|\.gitconfig|\.git-credentials|\.netrc|\.ssh\/|id_rsa|id_ed25519|id_ecdsa|known_hosts)/;
|
|
|
|
// Secret-bearing files for the source / encode / interpreter rules. Wider than
|
|
// CREDENTIAL_FILE (which also gates Read/Edit *paths*, so it must NOT include
|
|
// .env lest it block reading .env.example): a bash command that READS these is
|
|
// exfiltration. Mirrors the file set in bash-guard-hook.sh's source/interpreter
|
|
// rules.
|
|
const SECRET_FILE =
|
|
/(\.env\b|\/etc\/environment|\.git-credentials|\.netrc|\.git\/config|\.gitconfig|\/proc\/[^\s]*environ|\.profile|\.bashrc|\.zshrc|id_rsa|id_ed25519|id_ecdsa|\.ssh\/)/;
|
|
|
|
// git network/auth/branch-mutating ops — run against the SKELETONIZED command
|
|
// (see gitSkeleton) so a heredoc/echo that merely documents `git push` is not
|
|
// mistaken for invoking it. Mirrors bash-guard-hook.sh's git-ops rule.
|
|
const GIT_OPS =
|
|
/(^|[\s;&|])git\s+(fetch|pull|push|clone|remote|ls-remote|checkout|commit|merge|rebase|reset|cherry-pick|revert|tag\s+-d|update-ref|reflog\s+delete)/;
|
|
|
|
const INTERNAL_HOST =
|
|
/((https?|wss?):\/\/)?\/?(roboco-[a-z0-9_-]+|localhost|127\.0\.0\.1|0\.0\.0\.0)[:/]/;
|
|
|
|
const HTTP_CLIENT_LIB =
|
|
/(httpx|requests|urllib|aiohttp|http\.client|httplib|net\/http|net::http|node-fetch|axios|xmlhttprequest|websocket|fetch\s*\()/;
|
|
|
|
// Each check takes the lowercased bash command and returns a deny reason, or
|
|
// null to allow. Mirrors the categories in bash-guard-hook.sh.
|
|
const BASH_CHECKS = [
|
|
// (git-ops is checked first in denyBash, on the skeletonized command.)
|
|
(low) =>
|
|
CREDENTIAL_FILE.test(low)
|
|
? "command references a credential file or SSH key — the PAT is injected subprocess-side by the MCP layer, never read from these files."
|
|
: null,
|
|
(low) =>
|
|
/(^|[\s;&|])(source|\.)\s+[^|;&]*/.test(low) && SECRET_FILE.test(low)
|
|
? "sourcing a credential-bearing file (.env / .git-credentials / .netrc / ...) exposes secrets in the current shell."
|
|
: null,
|
|
(low) =>
|
|
/(^|[\s;&|])(python3?|perl|node|ruby|awk|sed)\s+[^|;&]*-[ce]\s/.test(low) &&
|
|
SECRET_FILE.test(low)
|
|
? "interpreter one-liner reads a credential file — ask for the value you need via the task description."
|
|
: null,
|
|
(low) =>
|
|
/\/proc\/(self|\d+|\$\$)\/(environ|cmdline|cwd|exe)/.test(low)
|
|
? "reading /proc/*/environ or /proc/*/cmdline can leak credentials."
|
|
: null,
|
|
(low) =>
|
|
/(^|[\s;&|])(curl|wget|http|https|httpie)\s[^|]*(github\.com|api\.github\.com)/.test(
|
|
low,
|
|
)
|
|
? "direct GitHub HTTP calls bypass the PAT handler — use the role-appropriate MCP verb."
|
|
: null,
|
|
(low) =>
|
|
/(^|[\s;&|])(curl|wget|http|https|httpie)\s/.test(low) && INTERNAL_HOST.test(low)
|
|
? "internal API calls bypass the gateway — use the MCP verbs (roboco-flow / roboco-do / roboco-git-readonly / roboco-optimal)."
|
|
: null,
|
|
(low) =>
|
|
HTTP_CLIENT_LIB.test(low) && INTERNAL_HOST.test(low)
|
|
? "reaching an internal host via an HTTP client bypasses the gateway, role manifest, tracing and auth (and can forge X-Agent-* headers). Use your MCP verbs."
|
|
: null,
|
|
(low) =>
|
|
/(python3?|uv\s+run|poetry\s+run|pipenv\s+run|pdm\s+run|hatch\s+run)/.test(low) &&
|
|
/(import\s+roboco|from\s+roboco|-m\s+roboco|roboco\.(mcp|services|runtime|foundation|api|enforcement)\b)/.test(
|
|
low,
|
|
)
|
|
? "importing or running roboco.* internals from the shell bypasses the MCP role manifest, tracing and auth. Use your role's MCP verbs."
|
|
: null,
|
|
(low) =>
|
|
/(^|[\s;&|]|env\s+|export\s+)roboco_agent_id\s*=/.test(low)
|
|
? "ROBOCO_AGENT_ID is your injected identity — overriding it forges another agent's identity. Never set or export it."
|
|
: null,
|
|
(low) =>
|
|
/(^|[\s;&|])(env|printenv)([\s]|$)/.test(low) &&
|
|
!/(^|[\s;&|])env\s+(-i|[a-z_][a-z0-9_]*=)/.test(low)
|
|
? "env / printenv can leak secrets. Ask for the specific value you need via the task description."
|
|
: null,
|
|
(low) =>
|
|
/(^|[\s;&|])set([\s]*$|[\s]*[|;&])/.test(low) ||
|
|
/(^|[\s;&|])(declare|typeset)\s+-[a-z]*[xp]/.test(low) ||
|
|
/(^|[\s;&|])export\s+-p([\s]|$)/.test(low) ||
|
|
/(^|[\s;&|])compgen\s+-[a-z]*[ve]/.test(low)
|
|
? "shell built-ins that dump variables/exports can leak credentials."
|
|
: null,
|
|
(low) =>
|
|
/(^|[\s;&|])(base64|od|xxd|hexdump|strings|uuencode)\s[^|;&]*(\.env|\.git\/config|\.gitconfig|\.git-credentials|\.netrc|\.ssh\/|id_rsa|id_ed25519)/.test(
|
|
low,
|
|
)
|
|
? "encoding/inspecting a credential file is still exfiltration."
|
|
: null,
|
|
(low) =>
|
|
/(^|[\s;&|])rm\s[^|;&]*-[a-z]*[rf][a-z]*\s/.test(low) &&
|
|
/(^|[\s;&|])rm\s[^|;&]*(\/app($|[\s/])|\/root|\/etc|\/var|\/usr|\/bin|\/sbin|\/lib|\/home|\s\/\s*(;|\||&|$))/.test(
|
|
low,
|
|
)
|
|
? "rm on a system path. Operate inside your own workspace only."
|
|
: null,
|
|
];
|
|
|
|
// Tools that take a file path we must keep away from credential files.
|
|
const PATH_TOOLS = new Set(["read", "edit", "write"]);
|
|
|
|
// Strip heredoc bodies and echo/printf literal args BEFORE the git-ops check —
|
|
// those are data the shell writes to a file, not commands it runs, so a
|
|
// README/heredoc that documents `git push` must not be mistaken for invoking
|
|
// it. Quoted args to an interpreter (`bash -c "... && git fetch"`) ARE executed
|
|
// and are not echo/printf/heredoc bodies, so they survive. Mirrors the
|
|
// git_skel logic in bash-guard-hook.sh; every other rule sees the full command.
|
|
function gitSkeleton(command) {
|
|
const lines = String(command || "").split("\n");
|
|
const opener = /<<-?\s*[^\sA-Za-z_]*([A-Za-z_]\w*)/;
|
|
const kept = [];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
kept.push(lines[i]);
|
|
const m = opener.exec(lines[i]);
|
|
if (m) {
|
|
const delim = m[1];
|
|
const dash = lines[i].includes("<<-");
|
|
i++;
|
|
while (i < lines.length) {
|
|
const body = lines[i];
|
|
const cand = dash ? body.trim() : body;
|
|
if (cand === delim) {
|
|
kept.push(body);
|
|
break;
|
|
}
|
|
i++;
|
|
}
|
|
}
|
|
}
|
|
return kept
|
|
.join("\n")
|
|
.replace(/(^|[\n;&|]|&&|\|\|)\s*(echo|printf)\b[^\n;&|]*/g, "$1");
|
|
}
|
|
|
|
function denyBash(command) {
|
|
const low = String(command || "").toLowerCase();
|
|
if (!low) return null;
|
|
if (GIT_OPS.test(gitSkeleton(command).toLowerCase())) {
|
|
return "shell git for network/auth/branch-mutating ops is blocked — use your role's MCP verb (commit, complete, i_am_done, ...).";
|
|
}
|
|
for (const check of BASH_CHECKS) {
|
|
const reason = check(low);
|
|
if (reason) return reason;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Named export (opencode's plugin convention) + loaded from opencode's plugin
|
|
// auto-discovery dir (~/.config/opencode/plugin/), where it's baked at image
|
|
// build — the simplest registration route (no config `plugin:` path needed).
|
|
// Hook firing verified live against grok-build-0.1.
|
|
export const RobocoSecretScrub = async () => {
|
|
return {
|
|
"tool.execute.before": async (input, output) => {
|
|
const tool = input?.tool;
|
|
const args = output?.args || {};
|
|
if (tool === "bash") {
|
|
const reason = denyBash(args.command);
|
|
if (reason) throw new Error(`Denied by roboco secret-scrub: ${reason}`);
|
|
return;
|
|
}
|
|
if (PATH_TOOLS.has(tool)) {
|
|
const path = String(args.filePath || args.path || "").toLowerCase();
|
|
if (path && CREDENTIAL_FILE.test(path)) {
|
|
throw new Error(
|
|
"Denied by roboco secret-scrub: access to a credential file / SSH key is blocked.",
|
|
);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
};
|