Skip to content
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Makefile
dr-logs
/.venv*
*.swp
CLAUDE.md
#CLAUDE.md
AGENTS.md
keeper_db.sqlite
__pycache__/
Expand Down
97 changes: 97 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## ⚠️ Do not edit vendored directories

**Never edit files under `keepercommander/discovery_common/` or `keepercommander/keeper_dag/`.** These are copied in from external "golden" repositories (`keeper-dag`, and the shared Gateway/KDNRM `discovery-common` code). Any edits made here will be **overwritten** the next time the directories are synced from upstream. If a change is needed in this code, make it in the upstream repo — not here. The same applies to generated protobuf files in `keepercommander/proto/` (`*_pb2.py` / `*.pyi`): regenerate from the `.proto` source, never hand-edit.

## What this is

Keeper Commander is a command-line and terminal-UI client for Keeper Password Manager and KeeperPAM. It is a Python package (`keepercommander`) that ships as the `keeper` console script. Beyond vault access it does enterprise administration, PAM (privileged access: rotation, tunnels, discovery), data import/export from other password managers, and can run as a REST service.

## Setup & running

```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pip install -e .
pip install -e '.[email]' # optional email-sending extras
```

Run the CLI:
- `keeper help` — list all commands
- `keeper shell` — interactive command shell
- `keeper supershell` — full terminal vault UI (Textual)
- `keeper <command> [args]` — run a single command and exit
- `python keeper.py ...` — equivalent entry point (calls `keepercommander.__main__:main`)

Config defaults to `config.json` (cwd or platform data dir); override with `KEEPER_CONFIG_FILE`. Set `KEEPER_COMMANDER_DEBUG` for debug logging.

## Testing

CI runs only `unit-tests/` on Python 3.8 and 3.14 (`.github/workflows/test-with-pytest.yml`). Supported range is 3.8–3.14.

```bash
pip install '.[test]'
pytest unit-tests/ # what CI runs
pytest unit-tests/test_sync_down.py # single file
pytest unit-tests/test_sync_down.py::TestClass::test_name # single test
pytest -m keeper_imports # smoke test: every module imports cleanly
```

Test marker semantics (see `pytest.ini`):
- `unit` — mocked, no live server (credential-provision)
- `e2e` — end-to-end, run manually (`pytest -m e2e`)
- `integration` — hits internal `dev.keepersecurity.com` accounts via a `config.json` (`tests/data_config.py`); not for general use
- `cross_enterprise` — excluded by default in `addopts`

Note: `pytest.ini` excludes `venv/`, ignores `unit-tests/test_command_utils.py` (circular import) and `unit-tests/test_login.py` (connection errors), and disables warnings. The `tests/` directory holds heavier integration/e2e suites that are *not* run by CI.

Lint config is `pylintrc`; `desired-pylint-warnings` documents which warning categories the team cares about.

## Architecture

**Entry & dispatch.** `__main__.py` loads config into a `KeeperParams`, then `cli.py` drives the REPL/one-shot execution. `cli.do_command()` is the central dispatcher: it parses a command line, resolves aliases, picks the right registry (`commands`, `enterprise_commands`, or `msp_commands`), and calls the command. Control characters in command input are rejected at this boundary.

**Commands.** Every command subclasses `Command` (or `ArgparseCommand`) in `keepercommander/commands/base.py`. The contract:
- `get_parser()` returns an `argparse.ArgumentParser`; `execute_args()` parses the raw string and dispatches to `execute(params, **kwargs)`.
- `is_authorised()` gates whether login is required.
- `GroupCommand` / `GroupCommandNew` compose sub-verbs (e.g. `pam <verb>`); they own their own sub-registries and aliases.
- Mixins `RecordMixin` / `FolderMixin` provide shared record/folder resolution helpers.

Commands are registered through `register_commands(commands, aliases, command_info)` in `commands/base.py`, which imports each command module and calls its `register_commands` / `register_command_info`. To add a command, create the module, implement the `Command`, and wire it into the appropriate `register_commands` function. The `commands/` subdirectories group large feature areas (`pam`, `pam_cloud`, `pam_import`, `discover`, `enterprise*`, `domain_management`, `remote_management`, `keeper_drive`, `pedm`, `scim`).

**Session & state.** `KeeperParams` (`params.py`) is the single mutable object threaded through everything: session tokens, the in-memory vault cache (records, folders, shared folders, teams), enterprise data, and `RestApiContext` (server, transmission/encryption keys, QRC/EC key negotiation). Commands read and mutate this object rather than passing data around.

**Network & data sync.** `api.py` is the transport layer: `login()`, `communicate()` / `communicate_rest()` (protobuf request/response with throttle retry), and `query_enterprise()`. `rest_api.py` / `loginv3.py` handle the low-level REST and login-v3 flows; `auth/` holds login-step and console-UI logic. `sync_down.py` pulls and decrypts the vault into `params`, then `prepare_folder_tree()` builds the folder hierarchy. Wire formats live in `proto/` (generated `*_pb2.py` — do not hand-edit). Crypto primitives are in `crypto.py`.

**Vault data model.** `vault.py` defines the record types: `KeeperRecord` (abstract) with `PasswordRecord` (v2), `TypedRecord` (v3, field-based with `TypedField`), `FileRecord`, `ApplicationRecord`. `record_facades.py` / `vault_extensions.py` provide typed views; `subfolder.py` models the folder tree.

**Local storage.** `storage/` (SQLite + in-memory DAOs) and `config_storage/` persist cache and config; secure config storage can be encrypted (`loader.SecureStorageException` path in `__main__.py`).

**PAM / discovery / graph.** `keeper_dag/` and `discovery_common/` implement the directed-acyclic graph (DAG) backing PAM discovery, record-linking, and rotation. `commands/pam/`, `commands/pam_cloud/`, and `commands/discover/` build on top of them. These two directories are **vendored copies** of external golden repos — see the warning at the top of this file before touching them.

**Importers.** `importer/` has per-product subpackages (1password, bitwarden, lastpass, keepass, dashlane, proton, thycotic, cyberark, etc.) plus generic csv/json. `imp_exp.py` orchestrates import/export.

**Service mode.** `service/` is a Flask-based REST API server exposing Commander commands over HTTP with API-key auth, rate limiting, and optional response encryption. See `keepercommander/service/README.md`. Managed via `service-create` / `service-start` / etc. commands.

**Plugins.** `plugins/` are rotation plugins loaded dynamically and registered like other commands.

## Style

Follow [PEP 8](https://peps.python.org/pep-0008/), with the project-specific settings enforced by `pylintrc`:
- **Line length: 100** (not PEP 8's default 79).
- `snake_case` for functions, methods, arguments, variables, and attributes.
- `PascalCase` for classes.
- `UPPER_CASE` for module-level and class constants.
- 4-space indentation, no tabs.

Run `pylint keepercommander/<module>.py` to check; `desired-pylint-warnings` documents which warning categories the team treats as meaningful.

## Conventions

- Match the surrounding file's style; most modules carry the Keeper ASCII-art header.
- Never edit `keeper_dag/`, `discovery_common/`, or generated `proto/` files (see the warning at the top of this file).
- Version lives in `keepercommander/__init__.py` (`__version__`); `setup.cfg` reads it via `attr:`.
2 changes: 1 addition & 1 deletion keepercommander/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
# Contact: commander@keepersecurity.com
#

__version__ = '18.0.6'
__version__ = '18.0.7'
10 changes: 7 additions & 3 deletions keepercommander/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def show_brief_help():
print(' --batch-mode Run in batch/non-interactive mode')
print(' --proxy PROXY Proxy server')
print(' --new-login Force full login (bypass persistent login)')
print(' --via-desktop Use Keeper Desktop bridge for login')
print(' --version Display version')
print('')
print('Getting Started:')
Expand Down Expand Up @@ -163,6 +164,8 @@ def show_brief_help():
parser.add_argument('--fail-on-throttle', action='store_true', help=fail_on_throttle_help)
parser.add_argument('--data-dir', dest='data_dir', action='store', help='Directory to use for Commander data (config, cache, etc.). Overrides environment variables.')
parser.add_argument('--new-login', dest='new_login', action='store_true', help='Force full login flow (bypass persistent login)')
parser.add_argument('--via-desktop', dest='via_desktop', action='store_true',
help='Use Keeper Desktop bridge for login in this Commander process')
parser.add_argument('--config-file', dest='config_file', action='store_true',
help='Store credentials in config.json instead of the OS-native keychain')
parser.add_argument('command', nargs='?', type=str, action='store', help='Command')
Expand Down Expand Up @@ -203,7 +206,6 @@ def main(from_package=False):

sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
opts, flags = parser.parse_known_args(sys.argv[1:])

# Store the original command arguments for proper reconstruction
if opts.command:
# Find where the command starts in the original args and take everything after it
Expand All @@ -226,8 +228,8 @@ def main(from_package=False):
'--launched-with-shortcut', '--proxy',
'--data-dir', '-ks', '-ku', '-kp', '-lwsc']
bool_main_parser_args = ['--version', '--debug', '--silent', '--batch-mode',
'--unmask-all', '--fail-on-throttle',
'--new-login', '--config-file']
'--unmask-all', '--fail-on-throttle',
'--new-login', '--via-desktop', '--config-file']
main_parser_args = value_main_parser_args + bool_main_parser_args

is_main_parser_arg = False
Expand Down Expand Up @@ -298,6 +300,8 @@ def main(from_package=False):
if opts.fail_on_throttle:
params.rest_context.fail_on_throttle = opts.fail_on_throttle

params.via_desktop_login = opts.via_desktop

if opts.password:
params.password = opts.password
else:
Expand Down
40 changes: 40 additions & 0 deletions keepercommander/auth/console_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@ def _stderr(msg=''):
print(msg, file=sys.stderr)


_HEADLESS_AUTH_MSG_SHOWN = False


def _is_interactive():
try:
return bool(sys.stdin) and sys.stdin.isatty()
except Exception:
return False


def _fail_headless_auth(step):
"""In headless/service mode, persistent login often needs a follow-up prompt
(password, SSO, 2FA, device approval) that cannot be answered. Log once and
cancel so the caller exits cleanly instead of looping or spamming getpass."""
global _HEADLESS_AUTH_MSG_SHOWN
if not _HEADLESS_AUTH_MSG_SHOWN:
_HEADLESS_AUTH_MSG_SHOWN = True
logging.error(
'Persistent login is not working in this non-interactive environment '
'(possibly due to an IP/location change). '
'Re-run Commander/Docker setup from this network, then restart the service.'
)
step.cancel()


class ConsoleLoginUi(login_steps.LoginUi):
def __init__(self):
self._show_device_approval_help = True
Expand All @@ -28,6 +53,9 @@ def __init__(self):
self._failed_password_attempt = 0

def on_device_approval(self, step):
if not _is_interactive():
_fail_headless_auth(step)
return
if self._show_device_approval_help:
_stderr(f"\n{Fore.YELLOW}Device Approval Required{Fore.RESET}\n")
_stderr(f"{Fore.CYAN}Select an approval method:{Fore.RESET}")
Expand Down Expand Up @@ -123,6 +151,9 @@ def two_factor_channel_to_desc(channel): # type: (login_steps.TwoFactorChannel
return 'Backup Codes'

def on_two_factor(self, step):
if not _is_interactive():
_fail_headless_auth(step)
return
channels = step.get_channels()

if self._show_two_factor_help:
Expand Down Expand Up @@ -273,6 +304,9 @@ def on_two_factor(self, step):
logging.warning(f'{Fore.YELLOW}Invalid 2FA code. Please try again.{Fore.RESET}')

def on_password(self, step):
if not _is_interactive():
_fail_headless_auth(step)
return
if self._show_password_help:
_stderr(f'{Fore.CYAN}Enter master password for {Fore.WHITE}{step.username}{Fore.RESET}')

Expand All @@ -293,6 +327,9 @@ def on_password(self, step):
step.cancel()

def on_sso_redirect(self, step):
if not _is_interactive():
_fail_headless_auth(step)
return
try:
wb = webbrowser.get()
wrappers = set('xdg-open|gvfs-open|gnome-open|x-www-browser|www-browser'.split('|'))
Expand Down Expand Up @@ -360,6 +397,9 @@ def on_sso_redirect(self, step):
break

def on_sso_data_key(self, step):
if not _is_interactive():
_fail_headless_auth(step)
return
if self._show_sso_data_key_help:
_stderr(f'\n{Fore.YELLOW}Device Approval Required for SSO{Fore.RESET}\n')
_stderr(f'{Fore.CYAN}Select an approval method:{Fore.RESET}')
Expand Down
Loading