2026-06-18 08:36:36 +02:00
// 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`.
//
2026-06-19 00:02:31 +02:00
// Baked into the plugin auto-discovery dir (~/.config/opencode/plugin/) at image
// build (named export, opencode's convention) — the same route as budget-feed.
// The agent's bash permission is a second gate via ROBOCO_GROK_BASH_PERMISSION.
2026-06-18 08:36:36 +02:00
//
2026-06-19 00:02:31 +02:00
// STATUS: the plugin loads in the live runtime (same auto-discovery dir as the
// live-confirmed budget-feed), but the deny-on-match path has not yet blocked a
// real command on the NAS — confirm before trusting it as the sole bash gate.
2026-06-18 08:36:36 +02:00
// 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)/ ;
2026-06-18 20:12:46 +02:00
// 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)/ ;
2026-06-18 08:36:36 +02:00
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 = [
2026-06-18 20:12:46 +02:00
// (git-ops is checked first in denyBash, on the skeletonized command.)
2026-06-18 08:36:36 +02:00
( 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 ,
2026-06-18 20:12:46 +02:00
( 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 ,
2026-06-18 08:36:36 +02:00
( 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" ]);
2026-06-18 20:12:46 +02:00
// 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" );
}
2026-06-18 08:36:36 +02:00
function denyBash ( command ) {
const low = String ( command || "" ). toLowerCase ();
if ( ! low ) return null ;
2026-06-18 20:12:46 +02:00
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, ...)." ;
}
2026-06-18 08:36:36 +02:00
for ( const check of BASH_CHECKS ) {
const reason = check ( low );
if ( reason ) return reason ;
}
return null ;
}
2026-06-18 21:59:25 +02:00
// 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.
2026-06-18 21:14:06 +02:00
export const RobocoSecretScrub = async () => {
2026-06-18 08:36:36 +02:00
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." ,
);
}
}
},
};
};