"""Core API for safecmd"""

# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_core.ipynb.

# %% auto 0
__all__ = ['default_cfg', 'cfg_path', 'ok_ops', 'ok_cmds', 'run', 'CmdSpec', 'parse_cfg', 'validate_cmd', 'DisallowedError',
           'DisallowedOps', 'DisallowedCmd', 'safe_run', 'bash', 'unsafe_bash', 'add_allowed_cmds', 'add_allowed_ops',
           'rm_allowed_cmds', 'rm_allowed_ops', 'main']

# %% ../nbs/01_core.ipynb
import subprocess,json,shutil
from fastcore.utils import *
from fastcore.xdg import xdg_config_home
from configparser import ConfigParser

from .bashxtract import *

# %% ../nbs/01_core.ipynb
def run(cmd, ignore_ex=False):
    "Run `cmd` in shell; return stdout (+ stderr if any); raise IOError on failure"
    res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    out = res.stdout.strip()
    if res.stderr: out += ('\n' if out else '') + res.stderr.strip()
    if ignore_ex: return (res.returncode, out)
    if res.returncode: raise IOError(out)
    return out

# %% ../nbs/01_core.ipynb
class CmdSpec(BasicRepr):
    def __init__(self,
        name,  # the command (str, will be split into tuple)
        denied=None):  # if set, these flags blocked
        self.name = tuple(name.split())
        self.denied = set(denied or [])

    @classmethod
    def from_str(cls, s):
        "Create from 'cmd:-flag1,-flag2' format"
        name, _, denied = s.partition(':')
        return cls(name, denied.split('|') if denied else None)
        
    def __hash__(self): return hash(self.name)
    def __eq__(self, b): return self.name==b.name
    
    def __repr__(self):
        s = ' '.join(self.name)
        if self.denied: s += f' !{self.denied}'
        return s
    
    def __call__(self, toks):
        "Returns True if allowed, False if no match or denied flag found"
        if tuple(toks[:len(self.name)]) != self.name: return False
        return not (self.denied and self.denied & set(toks))

# %% ../nbs/01_core.ipynb
default_cfg = '''[DEFAULT]
ok_ops = |, <, &&, ||, ;, |&, <&file, <&, >&

ok_cmds = cat, head, tail, less, more, bat
    # Directory listing
    ls, tree, locate
    # Search
    grep, rg, ag, ack, fgrep, egrep
    # Text processing
    cut, sort, uniq, wc, tr, column
    # File info
    file, stat, du, df, which, whereis, type
    # Comparison
    diff, cmp, comm
    # Archives
    unzip, gunzip, bunzip2, unrar
    # Network
    ping, dig, nslookup, host
    # System info
    date, cal, uptime, whoami, hostname, uname, printenv
    # Utilities
    echo, printf, yes, seq, basename, dirname, realpath
    # Git (read-only)
    git log, git show, git diff, git status, git branch, git tag, git remote,
    git stash list, git blame, git shortlog, git describe, git rev-parse,
    git ls-files, git ls-tree, git cat-file, git config --get, git config --list
    # Git (workspace)
    git fetch, git add, git commit, git switch, git checkout
    # Builtins
    cd, pwd, export, test, [, true, false
    # Deny-lists
    find:-exec|-execdir|-delete|-ok|-okdir
    rg:--pre
    tar:--to-command|--use-compress-program|-I|--transform|--checkpoint-action|--info-script|--new-volume-script
    curl:-o|--output|-O|--remote-name
'''

# %% ../nbs/01_core.ipynb
cfg_path = xdg_config_home() / 'safecmd' / 'config.ini'
if not cfg_path.exists(): cfg_path.mk_write(default_cfg)

# %% ../nbs/01_core.ipynb
def _split_set(s):
    "Split comma-separated string into set of stripped strings"
    return {o.strip() for o in s.split(',')} if s else set()

def _split_specs(s):
    "Split comma-separated string into set of CmdSpecs"
    return {CmdSpec.from_str(c.strip()) for c in s.split(',') if c.strip()} if s else set()

def parse_cfg(cfg_str):
    "Parse config string, return (ok_ops set, ok_cmds set of CmdSpecs)"
    cp = ConfigParser()
    cp.read_string(cfg_str)
    cfg = cp['DEFAULT']
    ok_ops = _split_set(cfg['ok_ops'])
    splitcmds = ','.join(cfg['ok_cmds'].splitlines())
    ok_cmds = _split_specs(splitcmds)
    return ok_ops, ok_cmds

# %% ../nbs/01_core.ipynb
ok_ops,ok_cmds = parse_cfg(cfg_path.read_text())

# %% ../nbs/01_core.ipynb
def validate_cmd(toks, cmds=None):
    "Check if toks matches an allowed command; returns False if denied flags present"
    if cmds is None: cmds = ok_cmds
    return any(spec(toks) for spec in cmds)

# %% ../nbs/01_core.ipynb
class DisallowedError(PermissionError):
    def __repr__(self): return f"{type(self).__name__}({self.args[0]!r})"

class DisallowedOps(DisallowedError):
    def __init__(self, ops): super().__init__(f"{ops}")

class DisallowedCmd(DisallowedError):
    def __init__(self, cmd): super().__init__(' '.join(cmd))

# %% ../nbs/01_core.ipynb
def safe_run(
    cmd:str,  # Bash command string to execute
    cmds:str=None,  # Allowed commands (comma-separated, config format); defaults to ok_cmds
    ops:str=None,  # Allowed operators (comma-separated); defaults to ok_ops
    add_cmds:str=None,  # Temp add these commands
    add_ops:str=None,  # Temp add these operators
    rm_cmds:str=None,  # Temp remove these commands
    rm_ops:str=None,  # Temp remove these operators
    ignore_ex:bool=False,  # If True, return (returncode, output) instead of raising on error
) -> str:  # Combined stdout/stderr output
    "Run `cmd` in shell if all commands and operators are in allowlists, else raise"
    eff_ops = _split_set(ops) if ops else ok_ops.copy()
    eff_cmds = _split_specs(cmds) if cmds else ok_cmds.copy()
    
    eff_ops |= _split_set(add_ops)
    eff_ops -= _split_set(rm_ops)
    eff_cmds |= _split_specs(add_cmds)
    eff_cmds -= {CmdSpec(c) for c in _split_set(rm_cmds)}
    
    commands, used_ops = extract_commands(cmd)
    if bad_ops := used_ops - eff_ops: raise DisallowedOps(bad_ops)
    for c in commands:
        if not validate_cmd(c, eff_cmds): raise DisallowedCmd(c)
    return run(cmd, ignore_ex=ignore_ex)

# %% ../nbs/01_core.ipynb
def bash(
    cmd:str,  # Bash command string to execute - all shell features like pipes and subcommands are supported
    rm_cmds:str=None,  # Temp remove these commands from allow list
    rm_ops:str=None  # Temp remove these operators from allow list
): # dict with 'success' or 'error' key; value is stdout+stderr for success, or error message otherwise
    """Run a bash shell command line safely and return the concatencated stdout and stderr.
    `cmd` is parsed and all calls are checked against an allow-list.
    If the command is not allowed, STOP and inform the user of the command run and error details; so they can decide whether to whitelist
    it or run it themselves.
    The default allow-list includes most standard unix commands and git subcommands that do not change state or are easily reverted.
    All operators are supported (including `;`, `&&`, `||`, `>&`, `<&`, pipes, and subshells), except output file redirection.
    rm_ params are comma-separated strs."""
    try: return {'success': safe_run(cmd, rm_cmds=rm_cmds, rm_ops=rm_ops)}
    except PermissionError as e: return {'error': e}

# %% ../nbs/01_core.ipynb
def unsafe_bash(
    cmd:str,  # Bash command string to execute - all shell features like pipes and subcommands are supported
    cmds:str=None,  # Allowed commands; defaults to ok_cmds; DO NOT USE without upfront user permission
    ops:str=None,  # Allowed operators; defaults to ok_ops; DO NOT USE without upfront user permission
    add_cmds:str=None,  # Temp add these commands to allow list; DO NOT USE without upfront user permission
    add_ops:str=None,  # Temp add these operators to allow list; DO NOT USE without upfront user permission
    rm_cmds:str=None,  # Temp remove these commands from allow list
    rm_ops:str=None,  # Temp remove these operators from allow list
): # dict with 'success' or 'error' key; value is stdout+stderr for success, or error message otherwise
    """Run a bash shell command line safely and return the output. `cmd` is parsed and all calls are checked against an allow-list.
    If the command is not allowed, STOP and inform the user of the command run and error details; so they can decide whether to whitelist
    it or run it themselves.
    The default allow-list includes most standard unix commands and git subcommands that do not change state or are easily reverted. All operators are supported (including `;`, `&&`, `||`, pipes, and subshells), except output redirection.
    cmds/ops and add_/rm_ params are comma-separated strs."""
    try: return {'success': safe_run(cmd, cmds, ops, add_cmds=add_cmds, add_ops=add_ops, rm_cmds=rm_cmds, rm_ops=rm_ops)}
    except PermissionError as e: return {'error': e}

# %% ../nbs/01_core.ipynb
def add_allowed_cmds(cmds):
    "Add comma-separated `cmds` to the allow list; (this can not be used as an LLM tool)"
    ok_cmds.update(_split_specs(cmds))

def add_allowed_ops(ops):
    "Add comma-separated `ops` to the allow list; (this can not be used as an LLM tool)"
    ok_ops.update(_split_set(ops))

def rm_allowed_cmds(cmds:str):
    "Remove comma-separated `cmds` from the allow list"
    ok_cmds.difference_update({CmdSpec(c) for c in _split_set(cmds)})

def rm_allowed_ops(ops):
    "Remove comma-separated `ops` from the allow list"
    ok_ops.difference_update(_split_set(ops))

# %% ../nbs/01_core.ipynb
import argparse,sys

# %% ../nbs/01_core.ipynb
def main():
    p = argparse.ArgumentParser(description='Run a command (kinda) safely')
    p.add_argument('cmd', nargs=argparse.REMAINDER, help='Command and arguments')
    args = p.parse_args()
    if not args.cmd: p.print_help(); sys.exit(1)
    try: print(safe_run(' '.join(args.cmd)))
    except DisallowedError as e: print(f"Command not allowed: {e}", file=sys.stderr); sys.exit(1)
