"""Core API for safecmd"""

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

# %% auto #0
__all__ = ['default_cfg', 'cfg_path', 'ok_dests', 'ok_cmds', 'run', 'CmdSpec', 'parse_cfg', 'validate_cmd', 'DisallowedError',
           'DisallowedCmd', 'DisallowedDest', 'normalize_dest', 'validate_dest', 'validate', 'safe_run', 'bash',
           'unsafe_bash', 'add_allowed_cmds', 'add_allowed_dests', 'rm_allowed_cmds', 'rm_allowed_dests', 'ex', 'main']

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

from .bashxtract import *

# %% ../nbs/01_core.ipynb #2da56009
def run(cmd, ignore_ex=False, split=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)
    if split:
        out,err = res.stdout.strip(), res.stderr.strip()
        if ignore_ex: return (res.returncode, out, err)
        if res.returncode: raise IOError(err or out)
        return (out, err)
    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 #a7683b2a
class CmdSpec(BasicRepr):
    def __init__(self,
        name,  # the command (str, will be split into tuple)
        denied=None,  # if set, these flags blocked
        exec_flags=None,  # flags whose next arg is a command to validate
        dest_flags=None,  # flags whose next arg is a destination to validate
        exec_pos=None,  # positional arg indices (0-based) that are commands to validate
        dest_pos=None   # positional arg indices (0-based) that are destinations to validate
    ):
        self.name = tuple(name.split())
        self.denied,self.exec_flags,self.dest_flags = set(denied or []),set(exec_flags or []),set(dest_flags or [])
        self.exec_pos,self.dest_pos = set(exec_pos or []),set(dest_pos or [])

    @classmethod
    def _split_pos(cls, vals):
        "Split list of values into (flags, positions) where $N entries become int positions"
        flags,pos = [],[]
        for v in vals:
            if v.startswith('$'): pos.append(int(v[1:]))
            else: flags.append(v)
        return flags, pos

    @classmethod
    def from_str(cls, s):
        "Create from 'cmd:-flag1|-flag2:exec=-exec|$0:dest=-o|$-1' format"
        parts = s.split(':')
        name,denied,exec_flags,dest_flags,exec_pos,dest_pos = parts[0],None,None,None,None,None
        for p in parts[1:]:
            if p.startswith('exec='):
                exec_flags, exec_pos = cls._split_pos(p[5:].split('|'))
            elif p.startswith('dest='):
                dest_flags, dest_pos = cls._split_pos(p[5:].split('|'))
            else: denied = p.split('|') if p else None
        return cls(name, denied, exec_flags, dest_flags, exec_pos, dest_pos)
        
    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}'
        if self.exec_flags: s += f' exec={self.exec_flags}'
        if self.exec_pos: s += f' exec_pos={self.exec_pos}'
        if self.dest_flags: s += f' dest={self.dest_flags}'
        if self.dest_pos: s += f' dest_pos={self.dest_pos}'
        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
        if not self.denied: return True
        for d in self.denied:
            if d in toks: return False
            if d.startswith('--'):
                if any(tok.startswith(d + '=') for tok in toks): return False
            elif len(d) == 2 and d[0] == '-':
                for tok in toks:
                    if tok.startswith('-') and not tok.startswith('--') and d[1] in tok[1:]: return False
        return True


# %% ../nbs/01_core.ipynb #af82b27d
default_cfg = '''[DEFAULT]
ok_dests = ./, /tmp

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
    # gh
    gh repo view, gh issue list, gh issue view, gh pr list, gh pr view, gh pr status, gh pr checks, gh pr diff
    gh release list, gh release view, gh run list, gh run view, gh workflow list, gh workflow view
    gh auth status, gh gist list, gh gist view, gh browse, gh search
    # nbdev
    nbdev_export, nbdev_clean
    # npm (read-only)
    npm list, npm ls, npm outdated, npm view, npm info, npm why, npm audit, npm config list, npm config get, npm search, npm pack
    # yarn (read-only)
    yarn list, yarn outdated, yarn why, yarn info, yarn config list, yarn config get
    # pnpm (read-only)
    pnpm list, pnpm ls, pnpm outdated, pnpm why, pnpm config list, pnpm config get
    # bun (read-only)
    bun pm ls, bun pm hash
    # js install
    npm install, yarn install, pnpm install, bun install
    # Modern Unix (read-only)
    bat, eza, exa, fd, fzf, dust, duf, tldr, zoxide, httpie, http, jq, yq
    # Docker (read-only)
    docker ps, docker images, docker logs, docker inspect, docker stats, docker top, docker diff, docker history, docker version, docker info
    # Docker (workspace - reversible)
    docker pull, docker build
    # AWS (read-only)
    aws s3 ls, aws s3 cp, aws sts get-caller-identity, aws iam get-user, aws iam list-users
    aws ec2 describe-instances, aws ec2 describe-vpcs, aws ec2 describe-security-groups
    aws logs describe-log-groups, aws logs filter-log-events, aws logs get-log-events
    aws lambda list-functions, aws lambda get-function
    aws cloudformation describe-stacks, aws cloudformation list-stacks
    aws rds describe-db-instances, aws dynamodb list-tables, aws dynamodb describe-table
    aws sqs list-queues, aws sns list-topics
    aws configure list, aws configure get
    # GCloud (read-only)
    gcloud config list, gcloud config get-value, gcloud auth list
    gcloud projects list, gcloud projects describe
    gcloud compute instances list, gcloud compute instances describe, gcloud compute zones list, gcloud compute regions list
    gcloud container clusters list, gcloud container clusters describe
    gcloud functions list, gcloud functions describe, gcloud functions logs read
    gcloud run services list, gcloud run services describe
    gcloud sql instances list, gcloud sql instances describe
    gcloud storage ls, gcloud storage cat
    gcloud logging read
    # toolslm
    folder2ctx, repo2ctx
    # Positional exec/dest handling
    env:exec=$0, xargs:exec=$0
    tee:dest=$0, ex:dest=$0, cp:dest=$-1, mv:dest=$-1
    # Exec/dest flag handling
    find:-delete|-ok|-okdir:exec=-exec|-execdir
    rg:--pre
    tar:--use-compress-program|--transform|--checkpoint-action|--info-script|--new-volume-script:exec=--to-command|-I
    curl:dest=-o|--output
    # Builtins
    cd, pwd, export, test, [, true, false
'''


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

# %% ../nbs/01_core.ipynb #0db55f9a
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_dests set, ok_cmds set of CmdSpecs)"
    cp = ConfigParser()
    cp.read_string(cfg_str)
    cfg = cp['DEFAULT']
    ok_dests = _split_set(cfg.get('ok_dests', './, /tmp'))
    splitcmds = ','.join(cfg['ok_cmds'].splitlines())
    ok_cmds = _split_specs(splitcmds)
    return ok_dests, ok_cmds

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

# %% ../nbs/01_core.ipynb #d7b92e47
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 #3f70a323
class DisallowedError(PermissionError):
    def __repr__(self): return f"{type(self).__name__}({self.args[0]!r})"

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

class DisallowedDest(DisallowedError):
    def __init__(self, dest): super().__init__(dest)

# %% ../nbs/01_core.ipynb #2a1s16635sh
def normalize_dest(dest):
    "Normalize destination to absolute path, expanding ~ and env vars"
    dest = os.path.expanduser(dest)
    dest = os.path.expandvars(dest)
    return os.path.normpath(os.path.abspath(dest))

def validate_dest(dest, dests=None):
    "Check if dest (resolved to absolute) matches an allowed destination pattern"
    if dests is None: dests = ok_dests
    abs_dest = normalize_dest(dest)
    for pattern in dests:
        abs_pattern = normalize_dest(pattern)
        if abs_dest.startswith(abs_pattern): return True
    return False

# %% ../nbs/01_core.ipynb #892pxeqemg6
def _build_flag_dicts(cmds):
    "Build exec_flags, dest_flags, exec_pos, dest_pos dicts from CmdSpec set"
    exec_flags,dest_flags,exec_pos,dest_pos = {},{},{},{}
    for spec in cmds:
        name = spec.name[0] if len(spec.name) == 1 else ' '.join(spec.name)
        if spec.exec_flags: exec_flags[name] = spec.exec_flags
        if spec.dest_flags: dest_flags[name] = spec.dest_flags
        if spec.exec_pos: exec_pos[name] = spec.exec_pos
        if spec.dest_pos: dest_pos[name] = spec.dest_pos
    return exec_flags, dest_flags, exec_pos, dest_pos

def validate(
    cmd:str,  # Bash command string to validate
    cmds=None,  # Allowed commands set; defaults to ok_cmds
    dests=None,  # Allowed destinations set; defaults to ok_dests
):
    "Validate `cmd` against allowlists; raises DisallowedCmd or DisallowedDest on failure"
    if cmds is None: cmds = ok_cmds
    if dests is None: dests = ok_dests
    exec_flags, dest_flags, exec_pos, dest_pos = _build_flag_dicts(cmds)
    commands, ops, redirects = extract_commands(cmd, exec_flags=exec_flags, dest_flags=dest_flags, dest_pos=dest_pos, exec_pos=exec_pos)
    for c in commands:
        if not validate_cmd(c, cmds): raise DisallowedCmd(c)
    for op, dest in redirects:
        if not validate_dest(dest, dests): raise DisallowedDest(dest)


# %% ../nbs/01_core.ipynb #a9de4db3
def safe_run(
    cmd:str,  # Bash command string to execute
    cmds:str=None,  # Allowed commands (comma-separated, config format); defaults to ok_cmds
    dests:str=None,  # Allowed destinations (comma-separated); defaults to ok_dests
    add_cmds:str=None,  # Temp add these commands
    add_dests:str=None,  # Temp add these destinations
    rm_cmds:str=None,  # Temp remove these commands
    rm_dests:str=None,  # Temp remove these destinations
    ignore_ex:bool=False,  # If True, return (returncode, output) instead of raising on error
    split:bool=False,  # If True, return stdout and stderr separately
) -> str:  # Combined stdout/stderr output
    "Run `cmd` in shell if all commands and destinations are in allowlists, else raise"
    eff_dests = _split_set(dests) if dests else ok_dests.copy()
    eff_cmds = _split_specs(cmds) if cmds else ok_cmds.copy()
    
    eff_dests |= _split_set(add_dests)
    eff_dests -= _split_set(rm_dests)
    eff_cmds |= _split_specs(add_cmds)
    eff_cmds -= {CmdSpec(c) for c in _split_set(rm_cmds)}
    
    validate(cmd, eff_cmds, eff_dests)
    return run(cmd, ignore_ex=ignore_ex, split=split)

# %% ../nbs/01_core.ipynb #28df0b13
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_dests:str=None  # Temp remove these destinations 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. Output redirects are validated against allowed destinations (default: ./ and /tmp).
    rm_ params are comma-separated strs."""
    try: return {'success': safe_run(cmd, rm_cmds=rm_cmds, rm_dests=rm_dests)}
    except PermissionError as e: return {'error': e}

# %% ../nbs/01_core.ipynb #1ed78ed3
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
    dests:str=None,  # Allowed destinations; defaults to ok_dests; 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_dests:str=None,  # Temp add these destinations to allow list; DO NOT USE without upfront user permission
    rm_cmds:str=None,  # Temp remove these commands from allow list
    rm_dests:str=None,  # Temp remove these destinations 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. Output redirects are validated against allowed destinations.
    cmds/dests and add_/rm_ params are comma-separated strs."""
    try: return {'success': safe_run(cmd, cmds, dests, add_cmds=add_cmds, add_dests=add_dests, rm_cmds=rm_cmds, rm_dests=rm_dests)}
    except PermissionError as e: return {'error': e}

# %% ../nbs/01_core.ipynb #7a9e1cb3
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_dests(dests):
    "Add comma-separated `dests` to the allow list; (this can not be used as an LLM tool)"
    ok_dests.update(_split_set(dests))

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_dests(dests):
    "Remove comma-separated `dests` from the allow list"
    ok_dests.difference_update(_split_set(dests))

# %% ../nbs/01_core.ipynb #e03a70e1
def ex(
    path:str, # The file to run `ex` on
    cmds:str # The commands to run (a 'heredoc' is used automatically, so embedded newlines work
):
    """Run ex commands on a file via bash.
    TIP: Great for in/dedent, join, `g/pat/cmd`, copy/cut/paste, etc.
    But for inserting/deleting/replacing lines/strs, use the dedicated tools where possible."""
    cmd = f"ex --clean -V1 {shlex.quote(path)} <<'EX_EOF'\n{cmds}\nx\nEX_EOF"
    rc,out,err = safe_run(cmd, ignore_ex=True, split=True)
    err_lines = err.split('Entering Ex mode.')[-1].splitlines()[1:]
    errs = [l for l in err_lines if l[:1]=='E' and l[1:2].isdigit()]
    if errs: return {'error': '\n'.join(errs)}
    return {'success': out.splitlines()+err_lines[-1:] or f'Applied to {path}'}

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

# %% ../nbs/01_core.ipynb #5b4b12fd
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)
