From 6858c9ea1418f27a63414ea4d1b30caacf0b9bca Mon Sep 17 00:00:00 2001 From: gmpassos Date: Thu, 4 Jun 2026 17:51:24 -0300 Subject: [PATCH] Refactor CommandKnowledgeBase into a plugin architecture Replace the monolithic knowledge table + switch with composable CommandKnowledgePlugins (one per domain) producing declarative CommandKnowledge entries. Entries carry richer metadata (category, platforms, description) plus a SecurityLevel risk hint, sub-command and argument rules (ExactFlag/PrefixFlag/TokenPresent/ArgRegex/ArgPredicate), per-entry WrapperSpec and an optional refine hook. - analyze() returns a CommandKnowledgeResult (capabilities, risk, matched entry, notes); capabilitiesFor() kept as a convenience seam. - Opt-in KnowledgeRiskDetector + CommandAnalysis.knowledgeRisk surface risk without changing default security verdicts. - Sub-command matching uses the first non-flag argument. - Broader coverage: dart/flutter, archives, cloud CLIs, more git, etc. BREAKING: removes extraExecutableCapabilities ctor param and the static wrapperCommands set; use plugins and WrapperSpec instead. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 40 ++ lib/command_shield.dart | 17 + lib/src/analysis/analyzer.dart | 20 +- lib/src/analysis/command_analysis.dart | 8 + .../capabilities/command_knowledge_base.dart | 543 +++++------------- .../knowledge/command_knowledge.dart | 315 ++++++++++ .../knowledge/command_knowledge_plugin.dart | 28 + .../knowledge/command_knowledge_result.dart | 51 ++ .../knowledge/plugins/archive_knowledge.dart | 45 ++ .../plugins/container_knowledge.dart | 43 ++ .../plugins/dart_flutter_knowledge.dart | 66 +++ .../knowledge/plugins/default_plugins.dart | 35 ++ .../plugins/environment_knowledge.dart | 23 + .../plugins/filesystem_knowledge.dart | 166 ++++++ .../knowledge/plugins/git_knowledge.dart | 93 +++ .../knowledge/plugins/knowledge_builders.dart | 22 + .../knowledge/plugins/network_knowledge.dart | 147 +++++ .../plugins/package_manager_knowledge.dart | 116 ++++ .../knowledge/plugins/process_knowledge.dart | 37 ++ .../knowledge/plugins/shell_knowledge.dart | 110 ++++ .../plugins/system_config_knowledge.dart | 48 ++ .../knowledge/plugins/windows_knowledge.dart | 67 +++ .../detectors/knowledge_risk_detector.dart | 57 ++ pubspec.yaml | 2 +- test/unit/capabilities/capability_test.dart | 12 +- .../capabilities/knowledge_base_test.dart | 102 ++++ .../capabilities/knowledge_plugins_test.dart | 122 ++++ 27 files changed, 1922 insertions(+), 413 deletions(-) create mode 100644 lib/src/capabilities/knowledge/command_knowledge.dart create mode 100644 lib/src/capabilities/knowledge/command_knowledge_plugin.dart create mode 100644 lib/src/capabilities/knowledge/command_knowledge_result.dart create mode 100644 lib/src/capabilities/knowledge/plugins/archive_knowledge.dart create mode 100644 lib/src/capabilities/knowledge/plugins/container_knowledge.dart create mode 100644 lib/src/capabilities/knowledge/plugins/dart_flutter_knowledge.dart create mode 100644 lib/src/capabilities/knowledge/plugins/default_plugins.dart create mode 100644 lib/src/capabilities/knowledge/plugins/environment_knowledge.dart create mode 100644 lib/src/capabilities/knowledge/plugins/filesystem_knowledge.dart create mode 100644 lib/src/capabilities/knowledge/plugins/git_knowledge.dart create mode 100644 lib/src/capabilities/knowledge/plugins/knowledge_builders.dart create mode 100644 lib/src/capabilities/knowledge/plugins/network_knowledge.dart create mode 100644 lib/src/capabilities/knowledge/plugins/package_manager_knowledge.dart create mode 100644 lib/src/capabilities/knowledge/plugins/process_knowledge.dart create mode 100644 lib/src/capabilities/knowledge/plugins/shell_knowledge.dart create mode 100644 lib/src/capabilities/knowledge/plugins/system_config_knowledge.dart create mode 100644 lib/src/capabilities/knowledge/plugins/windows_knowledge.dart create mode 100644 lib/src/security/detectors/knowledge_risk_detector.dart create mode 100644 test/unit/capabilities/knowledge_plugins_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b01d7..6a2ce09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +## 2.0.0 + +Plugin-based command knowledge base. + +### Added + +- Plugin architecture for command knowledge: knowledge is now contributed by + `CommandKnowledgePlugin`s, one per domain. Twelve built-in plugins ship by + default (`filesystem`, `archive`, `shell`, `environment`, `process`, `system`, + `network`, `container`, `packageManager`, `dartFlutter`, `git`, `windows`), + composed via `defaultKnowledgePlugins`. Register your own with + `CommandKnowledgeBase(plugins: [...])` or replace the built-ins entirely with + `includeDefaults: false`. +- Declarative `CommandKnowledge` entries with rich fields: `category`, + `platforms`, `description`, `baseCapabilities`, `baseRisk`, `subcommands`, + `argumentRules`, `wrapper` and an optional `refine` function hook. Argument + rules use composable `ArgumentMatch`es (`ExactFlag`, `PrefixFlag`, + `TokenPresent`, `ArgRegex`, `ArgPredicate`). +- `CommandKnowledgeBase.analyze()` returning a `CommandKnowledgeResult` + (capabilities, an aggregated `SecurityLevel` risk hint, the matched entry and + explanatory notes), plus `knowledgeFor()` and `allKnowledge`. +- `CommandAnalysis.knowledgeRisk`: the highest knowledge-base risk hint across a + command's invocations (advisory metadata). +- Opt-in `KnowledgeRiskDetector` that surfaces elevated knowledge-base risk + (e.g. a force push) as `knowledge-risk` security findings. Not part of + `SecurityAnalyzer.defaultDetectors`, so default verdicts are unchanged. +- Broader command coverage: Dart/Flutter sub-commands, archive/compression + tools, cloud CLIs (`gh`, `aws`, `gcloud`, `az`, `kubectl`), more `git` + sub-commands, additional package managers and Windows-specific tools. + +### Changed (breaking) + +- `CommandKnowledgeBase` is now composed from plugins. The + `extraExecutableCapabilities` constructor parameter and the static + `wrapperCommands` set have been removed; supply a `CommandKnowledgePlugin` + (e.g. `ListKnowledgePlugin`) and per-entry `WrapperSpec`s instead. +- Sub-command matching now uses the first non-flag argument rather than the + first argument, so leading global flags (e.g. `git --no-pager push`) no longer + hide the sub-command. + ## 1.0.0 Initial release. diff --git a/lib/command_shield.dart b/lib/command_shield.dart index 61342de..9c6249f 100644 --- a/lib/command_shield.dart +++ b/lib/command_shield.dart @@ -28,6 +28,22 @@ export 'src/ast/command_node.dart'; export 'src/capabilities/capability.dart'; export 'src/capabilities/capability_detector.dart'; export 'src/capabilities/command_knowledge_base.dart'; +export 'src/capabilities/knowledge/command_knowledge.dart'; +export 'src/capabilities/knowledge/command_knowledge_plugin.dart'; +export 'src/capabilities/knowledge/command_knowledge_result.dart'; +export 'src/capabilities/knowledge/plugins/archive_knowledge.dart'; +export 'src/capabilities/knowledge/plugins/container_knowledge.dart'; +export 'src/capabilities/knowledge/plugins/dart_flutter_knowledge.dart'; +export 'src/capabilities/knowledge/plugins/default_plugins.dart'; +export 'src/capabilities/knowledge/plugins/environment_knowledge.dart'; +export 'src/capabilities/knowledge/plugins/filesystem_knowledge.dart'; +export 'src/capabilities/knowledge/plugins/git_knowledge.dart'; +export 'src/capabilities/knowledge/plugins/network_knowledge.dart'; +export 'src/capabilities/knowledge/plugins/package_manager_knowledge.dart'; +export 'src/capabilities/knowledge/plugins/process_knowledge.dart'; +export 'src/capabilities/knowledge/plugins/shell_knowledge.dart'; +export 'src/capabilities/knowledge/plugins/system_config_knowledge.dart'; +export 'src/capabilities/knowledge/plugins/windows_knowledge.dart'; export 'src/classification/effect.dart'; export 'src/classification/effect_classifier.dart'; export 'src/command_shield.dart'; @@ -56,6 +72,7 @@ export 'src/security/detectors/command_substitution_detector.dart'; export 'src/security/detectors/dangerous_operator_detector.dart'; export 'src/security/detectors/destructive_command_detector.dart'; export 'src/security/detectors/env_expansion_detector.dart'; +export 'src/security/detectors/knowledge_risk_detector.dart'; export 'src/security/detectors/path_traversal_detector.dart'; export 'src/security/detectors/privilege_escalation_detector.dart'; export 'src/security/detectors/remote_exec_detector.dart'; diff --git a/lib/src/analysis/analyzer.dart b/lib/src/analysis/analyzer.dart index 04b77ab..c650c74 100644 --- a/lib/src/analysis/analyzer.dart +++ b/lib/src/analysis/analyzer.dart @@ -1,11 +1,13 @@ import '../capabilities/capability.dart'; import '../capabilities/capability_detector.dart'; +import '../capabilities/command_knowledge_base.dart'; import '../classification/effect.dart'; import '../classification/effect_classifier.dart'; import '../normalization/normalizer.dart'; import '../parser/parse_result.dart'; import '../security/security_analyzer.dart'; import '../security/security_detector.dart'; +import '../security/security_level.dart'; import 'command_analysis.dart'; /// Orchestrates the post-parse stages of the pipeline: @@ -20,14 +22,20 @@ final class Analyzer { CapabilityDetector? capabilityDetector, EffectClassifier? effectClassifier, SecurityAnalyzer? securityAnalyzer, + CommandKnowledgeBase? knowledgeBase, }) : _normalizer = normalizer ?? Normalizer.standard(), + _knowledgeBase = knowledgeBase ?? CommandKnowledgeBase.standard(), _capabilityDetector = capabilityDetector ?? - CapabilityDetector(normalizer: normalizer ?? Normalizer.standard()), + CapabilityDetector( + normalizer: normalizer ?? Normalizer.standard(), + knowledgeBase: knowledgeBase, + ), _effectClassifier = effectClassifier ?? const EffectClassifier(), _securityAnalyzer = securityAnalyzer ?? SecurityAnalyzer(); final Normalizer _normalizer; + final CommandKnowledgeBase _knowledgeBase; final CapabilityDetector _capabilityDetector; final EffectClassifier _effectClassifier; final SecurityAnalyzer _securityAnalyzer; @@ -49,6 +57,15 @@ final class Analyzer { ? {} : _effectClassifier.classify(capabilities); + var knowledgeRisk = SecurityLevel.safe; + for (final inv in parseResult.invocations) { + if (inv.executable.isEmpty) continue; + final exe = _normalizer.normalize(inv.executable); + knowledgeRisk = knowledgeRisk.max( + _knowledgeBase.analyze(exe, inv.arguments).risk, + ); + } + final report = _securityAnalyzer.analyze( SecurityContext( raw: parseResult.raw, @@ -68,6 +85,7 @@ final class Analyzer { effects: Set.unmodifiable(effects), securityLevel: report.level, findings: report.findings, + knowledgeRisk: knowledgeRisk, ); } } diff --git a/lib/src/analysis/command_analysis.dart b/lib/src/analysis/command_analysis.dart index 2390486..2df1a03 100644 --- a/lib/src/analysis/command_analysis.dart +++ b/lib/src/analysis/command_analysis.dart @@ -2,6 +2,7 @@ import 'package:meta/meta.dart' show immutable; import '../ast/command_node.dart'; import '../capabilities/capability.dart'; +import '../capabilities/command_knowledge_base.dart'; import '../classification/effect.dart'; import '../parser/parse_diagnostic.dart'; import '../security/security_finding.dart'; @@ -27,6 +28,7 @@ final class CommandAnalysis { required this.effects, required this.securityLevel, required this.findings, + this.knowledgeRisk = SecurityLevel.safe, }); /// The original command string. @@ -53,6 +55,12 @@ final class CommandAnalysis { /// The overall security severity. final SecurityLevel securityLevel; + /// The highest risk hint contributed by the command knowledge base across all + /// invocations (for example, a force push). This is advisory metadata derived + /// from the [CommandKnowledgeBase] and does **not** affect [securityLevel] + /// unless a `KnowledgeRiskDetector` is explicitly enabled. + final SecurityLevel knowledgeRisk; + /// The aggregated security findings, sorted by descending severity. final List findings; diff --git a/lib/src/capabilities/command_knowledge_base.dart b/lib/src/capabilities/command_knowledge_base.dart index beb7d85..1c0f08e 100644 --- a/lib/src/capabilities/command_knowledge_base.dart +++ b/lib/src/capabilities/command_knowledge_base.dart @@ -1,461 +1,188 @@ +import '../security/security_level.dart'; import 'capability.dart'; - -/// A data-driven knowledge base mapping commands (and their sub-commands and -/// arguments) to the [CommandCapability]s they exercise. +import 'knowledge/command_knowledge.dart'; +import 'knowledge/command_knowledge_plugin.dart'; +import 'knowledge/command_knowledge_result.dart'; +import 'knowledge/plugins/default_plugins.dart'; + +/// A plugin-composed knowledge base mapping commands (and their sub-commands and +/// arguments) to the [CommandCapability]s they exercise and a [SecurityLevel] +/// risk hint. +/// +/// Knowledge is contributed by [CommandKnowledgePlugin]s — one per domain (git, +/// filesystem, package managers, …). Construct with the defaults, extend them +/// with extra plugins, or replace them entirely: +/// +/// ```dart +/// // Defaults only. +/// final kb = CommandKnowledgeBase.standard(); /// -/// The base is intentionally heuristic and conservative: when in doubt it -/// errs toward reporting a capability rather than omitting it. It is also -/// extensible — construct one with extra [executableCapabilities] entries to -/// teach it about new tools. +/// // Defaults plus a custom plugin. +/// final kb = CommandKnowledgeBase(plugins: [MyToolKnowledge()]); +/// +/// // Only my plugins, no built-ins. +/// final kb = CommandKnowledgeBase(plugins: [MyToolKnowledge()], +/// includeDefaults: false); +/// ``` +/// +/// The base is intentionally heuristic and conservative: when in doubt it errs +/// toward reporting a capability rather than omitting it. final class CommandKnowledgeBase { - /// Creates a knowledge base, optionally extending the built-in tables with - /// [extraExecutableCapabilities]. + /// Creates a knowledge base from [plugins]. + /// + /// When [includeDefaults] is true (the default), [defaultKnowledgePlugins] + /// are registered first and any [plugins] are layered on top, so a later + /// plugin can override an executable defined by an earlier one. CommandKnowledgeBase({ - Map>? extraExecutableCapabilities, - }) : executableCapabilities = >{ - ..._builtinExecutables, - ...?extraExecutableCapabilities, - }; + List? plugins, + bool includeDefaults = true, + }) : _table = _buildTable([ + if (includeDefaults) ...defaultKnowledgePlugins, + ...?plugins, + ]); + + /// Creates a knowledge base from the built-in plugins only. + CommandKnowledgeBase.standard() : this(); - /// The (lower-cased) executable → capabilities table consulted by - /// [capabilitiesFor]. - final Map> executableCapabilities; + /// The (lower-cased) executable/alias → knowledge table. + final Map _table; + + static Map _buildTable( + List plugins, + ) { + final table = {}; + for (final plugin in plugins) { + for (final entry in plugin.entries) { + for (final name in entry.allNames) { + table[name.toLowerCase()] = entry; + } + } + } + return table; + } - /// Commands that wrap and then execute another command supplied in their - /// arguments (e.g. `sudo rm -rf /`). For these, the wrapped command's - /// capabilities are also attributed to the invocation. - static const Set wrapperCommands = { - 'sudo', - 'su', - 'doas', - 'pkexec', - 'runas', - 'env', - 'xargs', - 'time', - 'nice', - 'nohup', - 'timeout', - 'watch', - 'command', - 'exec', - 'builtin', - 'stdbuf', - }; + /// Every distinct knowledge entry in the base. + Iterable get allKnowledge => _table.values.toSet(); - /// Returns the set of capabilities for an invocation of [executable] (already - /// normalized) with [arguments]. + /// Returns the knowledge entry for [executable], or `null` if unknown. + CommandKnowledge? knowledgeFor(String executable) => + _table[executable.toLowerCase()]; + + /// Returns the set of capabilities for an invocation of [executable] with + /// [arguments]. + /// + /// A convenience wrapper over [analyze] for callers that only need the + /// capabilities (such as `CapabilityDetector`). Set capabilitiesFor( String executable, List arguments, - ) { - final result = {}; - _collect(executable.toLowerCase(), arguments, result, depth: 0); - return result; + ) => analyze(executable, arguments).capabilities; + + /// Performs a full analysis of an invocation of [executable] with + /// [arguments], returning the capabilities, an aggregated risk hint, the + /// matched knowledge entry and explanatory notes. + CommandKnowledgeResult analyze(String executable, List arguments) { + final match = KnowledgeMatch(); + final root = _collect(executable.toLowerCase(), arguments, match, depth: 0); + return CommandKnowledgeResult( + capabilities: match.capabilities, + risk: match.risk, + knowledge: root, + notes: match.notes, + ); } - void _collect( + /// Accumulates the knowledge for [exe] with [args] into [match] and returns + /// the matched entry for [exe] (the root invocation's entry), if any. + CommandKnowledge? _collect( String exe, List args, - Set out, { + KnowledgeMatch match, { required int depth, }) { - final base = executableCapabilities[exe]; - if (base != null) out.addAll(base); - - _refine(exe, args, out); + final entry = _table[exe]; + if (entry != null) { + match.addAll(entry.baseCapabilities); + match.raiseRisk(entry.baseRisk); + _refine(entry, args, match); + } // Any environment-variable token implies environment access. if (args.any(_looksLikeEnvReference)) { - out.add(CommandCapability.environmentAccess); + match.add(CommandCapability.environmentAccess); } // Recurse into wrapped commands (sudo/env/xargs/...), bounded to avoid // pathological inputs. - if (depth < 4 && wrapperCommands.contains(exe)) { - final wrapped = _wrappedCommand(exe, args); + if (entry?.wrapper != null && depth < 4) { + final wrapped = _wrappedCommand(entry!.wrapper!, args); if (wrapped != null) { - _collect(wrapped.executable, wrapped.arguments, out, depth: depth + 1); + _collect( + wrapped.executable, + wrapped.arguments, + match, + depth: depth + 1, + ); + } + } + return depth == 0 ? entry : null; + } + + void _refine( + CommandKnowledge entry, + List args, + KnowledgeMatch match, + ) { + final sub = _firstNonFlag(args)?.toLowerCase(); + if (sub != null) { + for (final rule in entry.subcommands) { + if (rule.names.contains(sub)) { + match.addAll(rule.capabilities); + match.raiseRisk(rule.risk); + match.note(rule.description); + } } } + for (final rule in entry.argumentRules) { + if (rule.match.matches(args)) { + match.addAll(rule.capabilities); + match.raiseRisk(rule.risk); + match.note(rule.description); + } + } + entry.refine?.call(args, match); } - _Wrapped? _wrappedCommand(String wrapper, List args) { - // Skip leading option flags and (for env) NAME=VALUE assignments. + _Wrapped? _wrappedCommand(WrapperSpec spec, List args) { var i = 0; while (i < args.length) { final a = args[i]; - if (a.startsWith('-')) { + if (spec.skipLeadingFlags && a.startsWith('-')) { i++; continue; } - if (wrapper == 'env' && a.contains('=')) { + if (spec.skipAssignments && a.contains('=')) { i++; continue; } break; } if (i >= args.length) return null; - final exe = args[i].toLowerCase(); - return _Wrapped(exe, args.sublist(i + 1)); + return _Wrapped(args[i].toLowerCase(), args.sublist(i + 1)); } - void _refine(String exe, List args, Set out) { - final sub = args.isNotEmpty ? args.first.toLowerCase() : ''; - switch (exe) { - case 'git': - if (const {'push'}.contains(sub)) { - out.add(CommandCapability.networkWrite); - } else if (const {'pull', 'clone', 'fetch', 'remote'}.contains(sub)) { - out.add(CommandCapability.networkRead); - } else if (const { - 'commit', - 'add', - 'init', - 'checkout', - 'merge', - 'reset', - 'rebase', - 'stash', - 'tag', - 'apply', - 'rm', - 'mv', - 'restore', - }.contains(sub)) { - out.add(CommandCapability.writeFilesystem); - } else { - out.add(CommandCapability.readFilesystem); - } - case 'docker': - case 'podman': - if (sub == 'push') { - out.add(CommandCapability.networkWrite); - } else if (const {'pull', 'run', 'build', 'login'}.contains(sub)) { - out - ..add(CommandCapability.networkRead) - ..add(CommandCapability.executePrograms); - } - case 'npm': - case 'pnpm': - case 'yarn': - if (const { - 'install', - 'i', - 'add', - 'ci', - 'update', - 'audit', - }.contains(sub)) { - out - ..add(CommandCapability.networkRead) - ..add(CommandCapability.writeFilesystem); - } - if (const {'publish'}.contains(sub)) { - out.add(CommandCapability.networkWrite); - } - case 'pip': - case 'pip3': - if (const {'install', 'download', 'wheel'}.contains(sub)) { - out - ..add(CommandCapability.networkRead) - ..add(CommandCapability.writeFilesystem); - } - case 'apt': - case 'apt-get': - case 'brew': - case 'dnf': - case 'yum': - case 'pacman': - if (const { - 'install', - 'update', - 'upgrade', - 'add', - 'fetch', - '-s', - }.contains(sub)) { - out - ..add(CommandCapability.networkRead) - ..add(CommandCapability.writeFilesystem) - ..add(CommandCapability.systemConfiguration); - } - case 'curl': - case 'wget': - if (_hasUploadFlag(args)) { - out.add(CommandCapability.networkWrite); - } - case 'ssh': - // `ssh host cmd` runs a remote program. - if (args.where((a) => !a.startsWith('-')).length > 1) { - out.add(CommandCapability.executePrograms); - } - case 'sed': - if (args.any((a) => a == '-i' || a.startsWith('-i'))) { - out.add(CommandCapability.writeFilesystem); - } - case 'find': - if (args.contains('-delete')) { - out.add(CommandCapability.deleteFilesystem); - } - if (args.contains('-exec') || args.contains('-execdir')) { - out.add(CommandCapability.executePrograms); - } + static String? _firstNonFlag(List args) { + for (final a in args) { + if (!a.startsWith('-')) return a; } + return null; } - static bool _hasUploadFlag(List args) => args.any( - (a) => - a == '-d' || - a == '--data' || - a == '-F' || - a == '--form' || - a == '-T' || - a == '--upload-file' || - a == '--data-binary' || - (a == '-X' || a == '--request'), - ); - static bool _looksLikeEnvReference(String token) => token.contains(RegExp(r'\$[A-Za-z_]')) || token.contains(RegExp(r'\$\{[A-Za-z_]')) || token.contains(RegExp(r'\$env:')) || token.contains(RegExp(r'%[A-Za-z_][A-Za-z0-9_]*%')); - - static final Map> - _builtinExecutables = >{ - // --- read-only --- - for (final c in const [ - 'ls', - 'cat', - 'head', - 'tail', - 'grep', - 'egrep', - 'fgrep', - 'pwd', - 'echo', - 'stat', - 'file', - 'wc', - 'less', - 'more', - 'which', - 'whereis', - 'whoami', - 'hostname', - 'date', - 'tree', - 'dir', - 'type', - 'sort', - 'uniq', - 'diff', - 'cmp', - 'dirname', - 'basename', - 'realpath', - 'readlink', - 'getconf', - 'id', - 'uname', - 'df', - 'du', - 'lsblk', - 'get-childitem', - 'get-content', - 'gci', - 'cut', - 'awk', - 'column', - 'fold', - 'nl', - 'tac', - 'strings', - 'md5sum', - 'sha256sum', - 'sha1sum', - 'cksum', - ]) - c: {CommandCapability.readFilesystem}, - - // --- write --- - for (final c in const [ - 'touch', - 'mkdir', - 'cp', - 'copy', - 'tee', - 'truncate', - 'ln', - 'install', - 'patch', - 'xcopy', - 'robocopy', - 'set-content', - 'add-content', - ]) - c: {CommandCapability.writeFilesystem}, - 'dd': {CommandCapability.writeFilesystem, CommandCapability.readFilesystem}, - 'mv': { - CommandCapability.writeFilesystem, - CommandCapability.deleteFilesystem, - }, - 'move': { - CommandCapability.writeFilesystem, - CommandCapability.deleteFilesystem, - }, - - // --- delete --- - for (final c in const [ - 'rm', - 'rmdir', - 'del', - 'erase', - 'unlink', - 'shred', - 'remove-item', - 'ri', - ]) - c: {CommandCapability.deleteFilesystem}, - - // --- execute --- - for (final c in const [ - 'bash', - 'sh', - 'zsh', - 'dash', - 'ksh', - 'fish', - 'csh', - 'tcsh', - 'python', - 'node', - 'ruby', - 'perl', - 'php', - 'java', - 'dart', - 'go', - 'cargo', - 'npx', - 'make', - 'gcc', - 'clang', - 'cc', - 'eval', - 'source', - 'cmd', - 'powershell', - 'deno', - 'flutter', - 'dotnet', - 'mvn', - 'gradle', - 'rake', - 'lua', - 'osascript', - 'rscript', - 'swift', - 'kotlin', - 'scala', - 'invoke-expression', - 'iex', - ]) - c: {CommandCapability.executePrograms}, - - // --- network read --- - for (final c in const [ - 'curl', - 'wget', - 'ftp', - 'sftp', - 'telnet', - 'ping', - 'dig', - 'nslookup', - 'host', - 'whois', - 'traceroute', - 'aria2c', - 'invoke-webrequest', - 'iwr', - 'invoke-restmethod', - 'irm', - ]) - c: {CommandCapability.networkRead}, - 'scp': { - CommandCapability.networkRead, - CommandCapability.networkWrite, - CommandCapability.readFilesystem, - }, - 'rsync': { - CommandCapability.networkRead, - CommandCapability.networkWrite, - CommandCapability.readFilesystem, - }, - 'nc': {CommandCapability.networkRead, CommandCapability.networkWrite}, - 'ncat': {CommandCapability.networkRead, CommandCapability.networkWrite}, - 'ssh': {CommandCapability.networkRead, CommandCapability.networkWrite}, - - // --- privilege escalation --- - for (final c in const ['sudo', 'su', 'doas', 'pkexec', 'runas']) - c: {CommandCapability.privilegeEscalation}, - - // --- system configuration --- - for (final c in const [ - 'chmod', - 'chown', - 'chgrp', - 'chattr', - 'mount', - 'umount', - 'useradd', - 'userdel', - 'usermod', - 'groupadd', - 'passwd', - 'systemctl', - 'service', - 'launchctl', - 'setfacl', - 'sysctl', - 'reg', - 'netsh', - 'defaults', - 'crontab', - 'iptables', - 'ufw', - 'firewall-cmd', - 'set-itemproperty', - 'new-service', - 'set-acl', - 'setx', - ]) - c: {CommandCapability.systemConfiguration}, - - // --- process management --- - for (final c in const [ - 'kill', - 'killall', - 'pkill', - 'ps', - 'top', - 'htop', - 'nice', - 'renice', - 'taskkill', - 'tasklist', - 'get-process', - 'stop-process', - 'pgrep', - ]) - c: {CommandCapability.processManagement}, - - // --- environment access --- - for (final c in const ['env', 'printenv', 'export', 'set', 'setenv']) - c: {CommandCapability.environmentAccess}, - }; } class _Wrapped { diff --git a/lib/src/capabilities/knowledge/command_knowledge.dart b/lib/src/capabilities/knowledge/command_knowledge.dart new file mode 100644 index 0000000..7283ffb --- /dev/null +++ b/lib/src/capabilities/knowledge/command_knowledge.dart @@ -0,0 +1,315 @@ +import 'package:meta/meta.dart' show immutable; + +import '../../security/security_level.dart'; +import '../capability.dart'; + +/// The broad domain a command belongs to. +/// +/// Categories are purely descriptive metadata; they do not affect capability +/// detection. They make the knowledge base browsable and let tooling group or +/// filter commands (for example, "show every package manager"). +enum KnowledgeCategory { + /// Version-control tools (`git`, `hg`, `svn`). + versionControl, + + /// Filesystem manipulation (`ls`, `cp`, `rm`, …). + filesystem, + + /// Archive and compression tools (`tar`, `zip`, `gzip`, …). + archive, + + /// Software package managers (`npm`, `pip`, `apt`, `brew`, …). + packageManager, + + /// Container and orchestration tools (`docker`, `podman`, `kubectl`). + container, + + /// Network clients and utilities (`curl`, `ssh`, `ping`, cloud CLIs). + network, + + /// Shells and command wrappers (`bash`, `sudo`, `env`, …). + shell, + + /// Language interpreters and build tools (`python`, `dart`, `make`, …). + interpreter, + + /// Process inspection and control (`ps`, `kill`, `top`, …). + process, + + /// System and security configuration (`chmod`, `systemctl`, `reg`, …). + system, + + /// Environment-variable utilities (`env`, `printenv`, `export`). + environment, + + /// Anything that does not fit a more specific category. + other, +} + +/// The operating systems on which a command is commonly available. +enum CommandPlatform { + /// Linux distributions. + linux, + + /// Apple macOS. + macos, + + /// Microsoft Windows. + windows, + + /// Available across all major platforms. + cross, +} + +/// A mutable accumulator passed to declarative rules and [KnowledgeRefiner] +/// hooks while a command is being analysed. +/// +/// Rules add capabilities, raise the risk level and append explanatory notes; +/// the registry collects the final state into a `CommandKnowledgeResult`. +final class KnowledgeMatch { + /// The capabilities accumulated so far. + final Set capabilities = {}; + + /// Human-readable notes from the rules that matched. + final List notes = []; + + SecurityLevel _risk = SecurityLevel.safe; + + /// The highest risk level observed so far. + SecurityLevel get risk => _risk; + + /// Adds a single [capability]. + void add(CommandCapability capability) => capabilities.add(capability); + + /// Adds every capability in [caps]. + void addAll(Iterable caps) => capabilities.addAll(caps); + + /// Raises [risk] to at least [level] (never lowers it). + void raiseRisk(SecurityLevel level) => _risk = _risk.max(level); + + /// Records an explanatory [note], ignoring blank or duplicate entries. + void note(String? note) { + if (note == null || note.isEmpty || notes.contains(note)) return; + notes.add(note); + } +} + +/// A predicate over the argument tokens of an invocation, used by +/// [ArgumentRule] to decide whether the rule applies. +/// +/// Subclasses cover the common cases declaratively; [ArgPredicate] is the +/// escape hatch for arbitrary logic. +@immutable +sealed class ArgumentMatch { + /// Const base constructor. + const ArgumentMatch(); + + /// Whether this match applies to [args]. + bool matches(List args); +} + +/// Matches when any argument is exactly equal to one of [flags]. +/// +/// Use for long/short flags that take no value, e.g. `{'-i', '--in-place'}`. +final class ExactFlag extends ArgumentMatch { + /// Creates an exact-flag match. + const ExactFlag(this.flags); + + /// The flags to look for. + final Set flags; + + @override + bool matches(List args) => args.any(flags.contains); +} + +/// Matches when any argument equals, or starts with, one of [prefixes]. +/// +/// Use for flags that may be glued to their value, e.g. `-i` matching both +/// `-i` and `-i.bak` (as `sed -i.bak`). +final class PrefixFlag extends ArgumentMatch { + /// Creates a prefix-flag match. + const PrefixFlag(this.prefixes); + + /// The flag prefixes to look for. + final Set prefixes; + + @override + bool matches(List args) => + args.any((a) => prefixes.any((p) => a == p || a.startsWith(p))); +} + +/// Matches when any of [tokens] appears anywhere in the arguments. +/// +/// Use for positional sub-flags such as `find … -delete` or `-exec`. +final class TokenPresent extends ArgumentMatch { + /// Creates a token-present match. + const TokenPresent(this.tokens); + + /// The tokens to look for. + final Set tokens; + + @override + bool matches(List args) => args.any(tokens.contains); +} + +/// Matches when any argument matches [pattern]. +final class ArgRegex extends ArgumentMatch { + /// Creates a regex match. + const ArgRegex(this.pattern); + + /// The pattern each argument is tested against. + final RegExp pattern; + + @override + bool matches(List args) => args.any(pattern.hasMatch); +} + +/// Matches according to an arbitrary [test] function — the escape hatch for +/// logic that the declarative matchers cannot express. +final class ArgPredicate extends ArgumentMatch { + /// Creates a predicate match. + const ArgPredicate(this.test); + + /// The predicate evaluated against the full argument list. + final bool Function(List args) test; + + @override + bool matches(List args) => test(args); +} + +/// A rule that attributes [capabilities] (and an optional elevated [risk]) when +/// the first non-flag argument — the "sub-command" — is one of [names]. +@immutable +final class SubcommandRule { + /// Creates a sub-command rule. + const SubcommandRule( + this.names, + this.capabilities, { + this.risk = SecurityLevel.safe, + this.description, + }); + + /// The sub-command names this rule applies to (lower-cased). + final Set names; + + /// The capabilities attributed when [names] matches. + final Set capabilities; + + /// The risk level this sub-command implies. + final SecurityLevel risk; + + /// An optional human-readable explanation. + final String? description; +} + +/// A rule that attributes [capabilities] (and an optional elevated [risk]) when +/// [match] applies to the invocation's arguments. +@immutable +final class ArgumentRule { + /// Creates an argument rule. + const ArgumentRule( + this.match, + this.capabilities, { + this.risk = SecurityLevel.safe, + this.description, + }); + + /// The condition under which this rule fires. + final ArgumentMatch match; + + /// The capabilities attributed when [match] applies. + final Set capabilities; + + /// The risk level this argument pattern implies. + final SecurityLevel risk; + + /// An optional human-readable explanation. + final String? description; +} + +/// Describes how a wrapper command (such as `sudo` or `env`) locates the +/// command it ultimately executes within its own arguments. +@immutable +final class WrapperSpec { + /// Creates a wrapper specification. + const WrapperSpec({ + this.skipLeadingFlags = true, + this.skipAssignments = false, + }); + + /// Whether leading `-`-prefixed option tokens are skipped before the wrapped + /// executable is found. + final bool skipLeadingFlags; + + /// Whether leading `NAME=VALUE` assignment tokens are skipped (as with + /// `env FOO=bar cmd`). + final bool skipAssignments; +} + +/// An optional Dart hook for command logic that the declarative rules cannot +/// express. It receives the invocation [args] and mutates [match] directly. +typedef KnowledgeRefiner = + void Function(List args, KnowledgeMatch match); + +/// All knowledge the base holds about a single command (executable). +/// +/// Entries are pure data plus optional function hooks; a plugin is simply a +/// list of them. The registry consults [baseCapabilities], then the matching +/// [subcommands] and [argumentRules], then any [refine] hook, accumulating the +/// result into a `CommandKnowledgeResult`. +@immutable +final class CommandKnowledge { + /// Creates a knowledge entry. + const CommandKnowledge({ + required this.executable, + required this.category, + this.aliases = const {}, + this.platforms = const {CommandPlatform.cross}, + this.description, + this.baseCapabilities = const {}, + this.baseRisk = SecurityLevel.safe, + this.subcommands = const [], + this.argumentRules = const [], + this.wrapper, + this.refine, + }); + + /// The canonical, lower-cased executable name (e.g. `git`). + final String executable; + + /// Alternate names that resolve to this same entry. + final Set aliases; + + /// The command's broad domain. + final KnowledgeCategory category; + + /// The platforms on which the command is commonly available. + final Set platforms; + + /// A short human-readable summary of what the command is. + final String? description; + + /// Capabilities the command always exercises, regardless of arguments. + final Set baseCapabilities; + + /// The default risk hint for the command, before sub-command or argument + /// refinement raises it. + final SecurityLevel baseRisk; + + /// Rules keyed on the first non-flag argument (the sub-command). + final List subcommands; + + /// Rules keyed on flags or argument patterns. + final List argumentRules; + + /// If non-null, this command wraps and then executes another command found in + /// its arguments (e.g. `sudo`, `env`, `xargs`). + final WrapperSpec? wrapper; + + /// An optional hook for bespoke logic beyond the declarative rules. + final KnowledgeRefiner? refine; + + /// Every name this entry is registered under: its [executable] plus + /// [aliases]. + Iterable get allNames => [executable, ...aliases]; +} diff --git a/lib/src/capabilities/knowledge/command_knowledge_plugin.dart b/lib/src/capabilities/knowledge/command_knowledge_plugin.dart new file mode 100644 index 0000000..b5a96c4 --- /dev/null +++ b/lib/src/capabilities/knowledge/command_knowledge_plugin.dart @@ -0,0 +1,28 @@ +import 'command_knowledge.dart'; + +/// A self-contained unit of command knowledge. +/// +/// Each plugin describes one domain (git, the filesystem, package managers, …) +/// by returning a list of [CommandKnowledge] entries. The `CommandKnowledgeBase` +/// composes plugins into a single lookup table, so teaching the base about new +/// tools is a matter of supplying another plugin. +abstract interface class CommandKnowledgePlugin { + /// A short identifier for the plugin (used in diagnostics and tests). + String get name; + + /// The command knowledge this plugin contributes. + List get entries; +} + +/// A [CommandKnowledgePlugin] backed by a fixed list of [entries], so callers +/// can register ad-hoc knowledge without declaring a class. +final class ListKnowledgePlugin implements CommandKnowledgePlugin { + /// Creates a list-backed plugin. + const ListKnowledgePlugin(this.name, this.entries); + + @override + final String name; + + @override + final List entries; +} diff --git a/lib/src/capabilities/knowledge/command_knowledge_result.dart b/lib/src/capabilities/knowledge/command_knowledge_result.dart new file mode 100644 index 0000000..1945774 --- /dev/null +++ b/lib/src/capabilities/knowledge/command_knowledge_result.dart @@ -0,0 +1,51 @@ +import 'package:meta/meta.dart' show immutable; + +import '../../security/security_level.dart'; +import '../capability.dart'; +import 'command_knowledge.dart'; + +/// The full result of analysing a single command invocation against the +/// command knowledge base. +/// +/// It bundles the detected [capabilities], an aggregated [risk] hint, the +/// matched [knowledge] entry (if the command was recognised) and any +/// explanatory [notes] gathered from the rules that fired. +@immutable +final class CommandKnowledgeResult { + /// Creates a knowledge result. + const CommandKnowledgeResult({ + required this.capabilities, + required this.risk, + required this.knowledge, + required this.notes, + }); + + /// A result for a command the base knows nothing about. + static const CommandKnowledgeResult unknown = CommandKnowledgeResult( + capabilities: {}, + risk: SecurityLevel.safe, + knowledge: null, + notes: [], + ); + + /// The capabilities the invocation may exercise. + final Set capabilities; + + /// The aggregated risk hint: the maximum of the matched entry's base risk, + /// every fired rule's risk, and any wrapped command's risk. + final SecurityLevel risk; + + /// The matched knowledge entry, or `null` if the command is unknown. + final CommandKnowledge? knowledge; + + /// Human-readable notes from the rules that matched, in order. + final List notes; + + /// Whether the command was recognised by the base. + bool get isKnown => knowledge != null; + + @override + String toString() => + 'CommandKnowledgeResult(known: $isKnown, risk: ${risk.name}, ' + 'capabilities: $capabilities)'; +} diff --git a/lib/src/capabilities/knowledge/plugins/archive_knowledge.dart b/lib/src/capabilities/knowledge/plugins/archive_knowledge.dart new file mode 100644 index 0000000..30426f2 --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/archive_knowledge.dart @@ -0,0 +1,45 @@ +import '../../capability.dart'; +import '../command_knowledge.dart'; +import '../command_knowledge_plugin.dart'; + +/// Knowledge about archive and compression tools. +/// +/// These both read inputs and write outputs, so they carry read + write +/// filesystem capabilities; extraction-only or list-only modes are still +/// conservatively reported as read + write. +final class ArchiveKnowledge implements CommandKnowledgePlugin { + /// Creates the archive knowledge plugin. + const ArchiveKnowledge(); + + @override + String get name => 'archive'; + + @override + List get entries => [ + for (final tool in const [ + 'tar', + 'zip', + 'unzip', + 'gzip', + 'gunzip', + 'bzip2', + 'bunzip2', + 'xz', + 'unxz', + 'zstd', + '7z', + '7za', + 'compress', + 'uncompress', + ]) + CommandKnowledge( + executable: tool, + category: KnowledgeCategory.archive, + description: 'Archive/compression tool (reads inputs, writes outputs).', + baseCapabilities: const { + CommandCapability.readFilesystem, + CommandCapability.writeFilesystem, + }, + ), + ]; +} diff --git a/lib/src/capabilities/knowledge/plugins/container_knowledge.dart b/lib/src/capabilities/knowledge/plugins/container_knowledge.dart new file mode 100644 index 0000000..51c9528 --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/container_knowledge.dart @@ -0,0 +1,43 @@ +import '../../capability.dart'; +import '../command_knowledge.dart'; +import '../command_knowledge_plugin.dart'; + +/// Knowledge about container engines and orchestration tools. +final class ContainerKnowledge implements CommandKnowledgePlugin { + /// Creates the container knowledge plugin. + const ContainerKnowledge(); + + @override + String get name => 'container'; + + @override + List get entries => [ + for (final c in const ['docker', 'podman', 'nerdctl']) + CommandKnowledge( + executable: c, + category: KnowledgeCategory.container, + description: 'Container engine.', + subcommands: const [ + SubcommandRule( + {'push'}, + {CommandCapability.networkWrite}, + description: 'Uploads an image to a registry.', + ), + SubcommandRule( + {'pull', 'run', 'build', 'login', 'create', 'start', 'exec'}, + {CommandCapability.networkRead, CommandCapability.executePrograms}, + description: 'Pulls images and/or runs containers.', + ), + ], + ), + const CommandKnowledge( + executable: 'docker-compose', + category: KnowledgeCategory.container, + description: 'Multi-container orchestration.', + baseCapabilities: { + CommandCapability.networkRead, + CommandCapability.executePrograms, + }, + ), + ]; +} diff --git a/lib/src/capabilities/knowledge/plugins/dart_flutter_knowledge.dart b/lib/src/capabilities/knowledge/plugins/dart_flutter_knowledge.dart new file mode 100644 index 0000000..4680665 --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/dart_flutter_knowledge.dart @@ -0,0 +1,66 @@ +import '../../capability.dart'; +import '../command_knowledge.dart'; +import '../command_knowledge_plugin.dart'; + +/// Knowledge about the Dart and Flutter command-line tools. +final class DartFlutterKnowledge implements CommandKnowledgePlugin { + /// Creates the Dart/Flutter knowledge plugin. + const DartFlutterKnowledge(); + + @override + String get name => 'dartFlutter'; + + // Both `dart` and `flutter` execute code by default (run/test/compile/build). + static const _subcommands = [ + SubcommandRule( + {'pub', 'packages'}, + {CommandCapability.networkRead, CommandCapability.writeFilesystem}, + description: 'Resolves and downloads package dependencies.', + ), + SubcommandRule( + {'format', 'fix'}, + {CommandCapability.writeFilesystem}, + description: 'Rewrites source files.', + ), + SubcommandRule( + {'run', 'test', 'compile', 'build', 'analyze', 'create'}, + {CommandCapability.executePrograms}, + description: 'Runs or builds Dart/Flutter code.', + ), + ]; + + static const _argumentRules = [ + ArgumentRule( + ArgPredicate(_isPubPublish), + {CommandCapability.networkWrite}, + description: '`pub publish` uploads a package to pub.dev.', + ), + ]; + + @override + List get entries => const [ + CommandKnowledge( + executable: 'dart', + category: KnowledgeCategory.interpreter, + description: 'Dart SDK command-line tool.', + baseCapabilities: {CommandCapability.executePrograms}, + subcommands: _subcommands, + argumentRules: _argumentRules, + ), + CommandKnowledge( + executable: 'flutter', + category: KnowledgeCategory.interpreter, + description: 'Flutter SDK command-line tool.', + baseCapabilities: {CommandCapability.executePrograms}, + subcommands: _subcommands, + argumentRules: _argumentRules, + ), + ]; + + static bool _isPubPublish(List args) { + final nonFlags = args.where((a) => !a.startsWith('-')).toList(); + return nonFlags.length >= 2 && + (nonFlags[0] == 'pub' || nonFlags[0] == 'packages') && + nonFlags[1] == 'publish'; + } +} diff --git a/lib/src/capabilities/knowledge/plugins/default_plugins.dart b/lib/src/capabilities/knowledge/plugins/default_plugins.dart new file mode 100644 index 0000000..82f427d --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/default_plugins.dart @@ -0,0 +1,35 @@ +import '../command_knowledge_plugin.dart'; +import 'archive_knowledge.dart'; +import 'container_knowledge.dart'; +import 'dart_flutter_knowledge.dart'; +import 'environment_knowledge.dart'; +import 'filesystem_knowledge.dart'; +import 'git_knowledge.dart'; +import 'network_knowledge.dart'; +import 'package_manager_knowledge.dart'; +import 'process_knowledge.dart'; +import 'shell_knowledge.dart'; +import 'system_config_knowledge.dart'; +import 'windows_knowledge.dart'; + +/// The built-in plugins composed by `CommandKnowledgeBase` when no explicit set +/// is supplied (or when `includeDefaults` is left enabled). +/// +/// Order matters only for duplicate executable names: later plugins override +/// earlier ones. The list is ordered so that specialised plugins (git, +/// dart/flutter, package managers, containers) come after the generic ones. +const List defaultKnowledgePlugins = + [ + FilesystemKnowledge(), + ArchiveKnowledge(), + ShellKnowledge(), + EnvironmentKnowledge(), + ProcessKnowledge(), + SystemConfigKnowledge(), + NetworkKnowledge(), + ContainerKnowledge(), + PackageManagerKnowledge(), + DartFlutterKnowledge(), + GitKnowledge(), + WindowsKnowledge(), + ]; diff --git a/lib/src/capabilities/knowledge/plugins/environment_knowledge.dart b/lib/src/capabilities/knowledge/plugins/environment_knowledge.dart new file mode 100644 index 0000000..d733add --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/environment_knowledge.dart @@ -0,0 +1,23 @@ +import '../../capability.dart'; +import '../command_knowledge.dart'; +import '../command_knowledge_plugin.dart'; +import 'knowledge_builders.dart'; + +/// Knowledge about environment-variable utilities. +/// +/// Note: `env` itself lives in the shell plugin because it is also a command +/// wrapper. +final class EnvironmentKnowledge implements CommandKnowledgePlugin { + /// Creates the environment knowledge plugin. + const EnvironmentKnowledge(); + + @override + String get name => 'environment'; + + @override + List get entries => simpleEntries( + const ['printenv', 'export', 'set', 'setenv'], + KnowledgeCategory.environment, + const {CommandCapability.environmentAccess}, + ); +} diff --git a/lib/src/capabilities/knowledge/plugins/filesystem_knowledge.dart b/lib/src/capabilities/knowledge/plugins/filesystem_knowledge.dart new file mode 100644 index 0000000..f711801 --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/filesystem_knowledge.dart @@ -0,0 +1,166 @@ +import '../../capability.dart'; +import '../command_knowledge.dart'; +import '../command_knowledge_plugin.dart'; +import 'knowledge_builders.dart'; + +/// Knowledge about filesystem inspection and manipulation commands. +final class FilesystemKnowledge implements CommandKnowledgePlugin { + /// Creates the filesystem knowledge plugin. + const FilesystemKnowledge(); + + @override + String get name => 'filesystem'; + + static const _fs = KnowledgeCategory.filesystem; + + @override + List get entries => [ + // --- read-only inspection --- + ...simpleEntries( + const [ + 'ls', + 'cat', + 'head', + 'tail', + 'grep', + 'egrep', + 'fgrep', + 'pwd', + 'echo', + 'stat', + 'file', + 'wc', + 'less', + 'more', + 'which', + 'whereis', + 'whoami', + 'hostname', + 'date', + 'tree', + 'dir', + 'type', + 'sort', + 'uniq', + 'diff', + 'cmp', + 'dirname', + 'basename', + 'realpath', + 'readlink', + 'getconf', + 'id', + 'uname', + 'df', + 'du', + 'lsblk', + 'get-childitem', + 'get-content', + 'gci', + 'cut', + 'awk', + 'column', + 'fold', + 'nl', + 'tac', + 'strings', + 'md5sum', + 'sha256sum', + 'sha1sum', + 'cksum', + ], + _fs, + const {CommandCapability.readFilesystem}, + ), + + // --- write --- + ...simpleEntries( + const [ + 'touch', + 'mkdir', + 'cp', + 'copy', + 'tee', + 'truncate', + 'ln', + 'install', + 'patch', + 'xcopy', + 'robocopy', + 'set-content', + 'add-content', + 'split', + ], + _fs, + const {CommandCapability.writeFilesystem}, + ), + + // --- delete --- + ...simpleEntries( + const [ + 'rm', + 'rmdir', + 'del', + 'erase', + 'unlink', + 'shred', + 'remove-item', + 'ri', + ], + _fs, + const {CommandCapability.deleteFilesystem}, + ), + + // --- special cases --- + const CommandKnowledge( + executable: 'dd', + category: _fs, + description: 'Low-level copy/convert of files and devices.', + baseCapabilities: { + CommandCapability.readFilesystem, + CommandCapability.writeFilesystem, + }, + ), + for (final mv in const ['mv', 'move']) + CommandKnowledge( + executable: mv, + category: _fs, + description: 'Moves or renames files (write + delete of the source).', + baseCapabilities: const { + CommandCapability.writeFilesystem, + CommandCapability.deleteFilesystem, + }, + ), + const CommandKnowledge( + executable: 'sed', + category: _fs, + description: 'Stream editor; `-i` edits files in place.', + baseCapabilities: {CommandCapability.readFilesystem}, + argumentRules: [ + ArgumentRule( + PrefixFlag({'-i', '--in-place'}), + {CommandCapability.writeFilesystem}, + description: 'In-place edit writes to the input file.', + ), + ], + ), + const CommandKnowledge( + executable: 'find', + category: _fs, + description: 'Recursively searches the filesystem.', + baseCapabilities: {CommandCapability.readFilesystem}, + argumentRules: [ + ArgumentRule( + TokenPresent({'-delete'}), + {CommandCapability.deleteFilesystem}, + description: '`-delete` removes matched entries.', + ), + ArgumentRule( + TokenPresent({'-exec', '-execdir'}), + {CommandCapability.executePrograms}, + description: '`-exec` runs a program per match.', + ), + ], + ), + ]; +} diff --git a/lib/src/capabilities/knowledge/plugins/git_knowledge.dart b/lib/src/capabilities/knowledge/plugins/git_knowledge.dart new file mode 100644 index 0000000..54d6a33 --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/git_knowledge.dart @@ -0,0 +1,93 @@ +import '../../../security/security_level.dart'; +import '../../capability.dart'; +import '../command_knowledge.dart'; +import '../command_knowledge_plugin.dart'; + +/// Knowledge about the `git` version-control tool and its sub-commands. +final class GitKnowledge implements CommandKnowledgePlugin { + /// Creates the git knowledge plugin. + const GitKnowledge(); + + @override + String get name => 'git'; + + @override + List get entries => const [ + CommandKnowledge( + executable: 'git', + category: KnowledgeCategory.versionControl, + description: 'Distributed version-control system.', + // Read-only is the safe default for unrecognised sub-commands (log, + // status, diff, show, blame, …). + baseCapabilities: {CommandCapability.readFilesystem}, + subcommands: [ + SubcommandRule( + {'push'}, + {CommandCapability.networkWrite}, + description: 'Uploads commits to a remote.', + ), + SubcommandRule( + {'send-email'}, + {CommandCapability.networkWrite}, + description: 'Sends patches over email.', + ), + SubcommandRule( + {'pull', 'clone', 'fetch', 'remote', 'submodule'}, + {CommandCapability.networkRead}, + description: 'Downloads objects or refs from a remote.', + ), + SubcommandRule( + { + 'commit', + 'add', + 'init', + 'checkout', + 'switch', + 'merge', + 'reset', + 'rebase', + 'stash', + 'tag', + 'apply', + 'rm', + 'mv', + 'restore', + 'clean', + 'worktree', + 'bisect', + 'cherry-pick', + 'revert', + 'gc', + 'reflog', + 'am', + 'format-patch', + }, + {CommandCapability.writeFilesystem}, + description: 'Modifies the working tree or repository.', + ), + ], + argumentRules: [ + ArgumentRule( + ArgPredicate(_isForcedPush), + // Capabilities already come from the `push` sub-command rule; this + // rule only elevates the risk hint. + {CommandCapability.networkWrite}, + risk: SecurityLevel.mediumRisk, + description: 'A force push can overwrite remote history.', + ), + ], + ), + ]; +} + +/// Whether the arguments describe `git push … --force` (or `-f`/`--force-with-lease`). +bool _isForcedPush(List args) { + final firstNonFlag = args.cast().firstWhere( + (a) => a != null && !a.startsWith('-'), + orElse: () => null, + ); + if (firstNonFlag != 'push') return false; + return args.any( + (a) => a == '-f' || a == '--force' || a.startsWith('--force-with-lease'), + ); +} diff --git a/lib/src/capabilities/knowledge/plugins/knowledge_builders.dart b/lib/src/capabilities/knowledge/plugins/knowledge_builders.dart new file mode 100644 index 0000000..f6b50cd --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/knowledge_builders.dart @@ -0,0 +1,22 @@ +import '../../capability.dart'; +import '../command_knowledge.dart'; + +/// Builds a list of simple [CommandKnowledge] entries that share the same +/// [category], [platforms] and [capabilities] — one entry per name in [names]. +/// +/// Used by plugins for the many commands whose capabilities do not depend on +/// their arguments (e.g. every read-only inspection tool). +List simpleEntries( + Iterable names, + KnowledgeCategory category, + Set capabilities, { + Set platforms = const {CommandPlatform.cross}, +}) => [ + for (final n in names) + CommandKnowledge( + executable: n, + category: category, + platforms: platforms, + baseCapabilities: capabilities, + ), +]; diff --git a/lib/src/capabilities/knowledge/plugins/network_knowledge.dart b/lib/src/capabilities/knowledge/plugins/network_knowledge.dart new file mode 100644 index 0000000..8588eb1 --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/network_knowledge.dart @@ -0,0 +1,147 @@ +import '../../capability.dart'; +import '../command_knowledge.dart'; +import '../command_knowledge_plugin.dart'; +import 'knowledge_builders.dart'; + +/// Knowledge about network clients, transfer tools and cloud CLIs. +final class NetworkKnowledge implements CommandKnowledgePlugin { + /// Creates the network knowledge plugin. + const NetworkKnowledge(); + + @override + String get name => 'network'; + + static const _net = KnowledgeCategory.network; + + @override + List get entries => [ + // --- network read clients --- + ...simpleEntries( + const [ + 'ftp', + 'sftp', + 'telnet', + 'ping', + 'dig', + 'nslookup', + 'host', + 'whois', + 'traceroute', + 'aria2c', + 'invoke-webrequest', + 'iwr', + 'invoke-restmethod', + 'irm', + ], + _net, + const {CommandCapability.networkRead}, + ), + + // --- curl / wget: read by default, write when uploading --- + for (final c in const ['curl', 'wget']) + CommandKnowledge( + executable: c, + category: _net, + description: 'Transfers data to or from a server.', + baseCapabilities: const {CommandCapability.networkRead}, + argumentRules: const [ + ArgumentRule( + ArgPredicate(_hasUploadFlag), + {CommandCapability.networkWrite}, + description: 'Upload/POST flags send data to the server.', + ), + ], + ), + + // --- bidirectional transfer --- + for (final c in const ['scp', 'rsync']) + CommandKnowledge( + executable: c, + category: _net, + description: 'Copies files to/from a remote host.', + baseCapabilities: const { + CommandCapability.networkRead, + CommandCapability.networkWrite, + CommandCapability.readFilesystem, + }, + ), + for (final c in const ['nc', 'ncat']) + CommandKnowledge( + executable: c, + category: _net, + description: 'Reads and writes raw network connections.', + baseCapabilities: const { + CommandCapability.networkRead, + CommandCapability.networkWrite, + }, + ), + const CommandKnowledge( + executable: 'ssh', + category: _net, + description: 'Secure shell; can run a remote command.', + baseCapabilities: { + CommandCapability.networkRead, + CommandCapability.networkWrite, + }, + argumentRules: [ + ArgumentRule( + ArgPredicate(_sshHasRemoteCommand), + {CommandCapability.executePrograms}, + description: 'A trailing command runs a program on the remote host.', + ), + ], + ), + + // --- cloud / platform CLIs (network + can execute remote actions) --- + for (final c in const ['gh', 'aws', 'gcloud', 'az', 'kubectl', 'helm']) + CommandKnowledge( + executable: c, + category: _net, + description: 'Cloud/platform CLI (network access; may run actions).', + baseCapabilities: const { + CommandCapability.networkRead, + CommandCapability.networkWrite, + }, + ), + for (final c in const ['http', 'https', 'httpie', 'xh']) + CommandKnowledge( + executable: c, + category: _net, + description: 'HTTP client.', + baseCapabilities: const {CommandCapability.networkRead}, + argumentRules: const [ + ArgumentRule( + ArgPredicate(_httpieHasBody), + {CommandCapability.networkWrite}, + description: 'A request body or non-GET method sends data.', + ), + ], + ), + ]; + + static bool _hasUploadFlag(List args) => args.any( + (a) => + a == '-d' || + a == '--data' || + a == '-F' || + a == '--form' || + a == '-T' || + a == '--upload-file' || + a == '--data-binary' || + a == '-X' || + a == '--request', + ); + + static bool _sshHasRemoteCommand(List args) => + args.where((a) => !a.startsWith('-')).length > 1; + + static bool _httpieHasBody(List args) => args.any( + (a) => + a == 'POST' || + a == 'PUT' || + a == 'PATCH' || + a == 'DELETE' || + a.contains('=') || + a.startsWith('@'), + ); +} diff --git a/lib/src/capabilities/knowledge/plugins/package_manager_knowledge.dart b/lib/src/capabilities/knowledge/plugins/package_manager_knowledge.dart new file mode 100644 index 0000000..af6c51a --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/package_manager_knowledge.dart @@ -0,0 +1,116 @@ +import '../../../security/security_level.dart'; +import '../../capability.dart'; +import '../command_knowledge.dart'; +import '../command_knowledge_plugin.dart'; + +/// Knowledge about software package managers. +/// +/// Installing/updating packages downloads code from the network, writes it to +/// disk and (for system managers) reconfigures the system — and runs arbitrary +/// install scripts, so it is flagged as a medium risk. +final class PackageManagerKnowledge implements CommandKnowledgePlugin { + /// Creates the package-manager knowledge plugin. + const PackageManagerKnowledge(); + + @override + String get name => 'packageManager'; + + static const _pm = KnowledgeCategory.packageManager; + + // Capabilities for a user-space install (downloads + writes project files). + static const _userInstall = SubcommandRule( + { + 'install', + 'i', + 'add', + 'ci', + 'update', + 'upgrade', + 'audit', + 'download', + 'wheel', + 'get', + 'restore', + 'sync', + }, + {CommandCapability.networkRead, CommandCapability.writeFilesystem}, + risk: SecurityLevel.mediumRisk, + description: 'Downloads and installs packages (runs install scripts).', + ); + + static const _publish = SubcommandRule( + {'publish'}, + {CommandCapability.networkWrite}, + description: 'Uploads a package to a registry.', + ); + + @override + List get entries => [ + // --- user-space managers --- + for (final c in const ['npm', 'pnpm', 'yarn', 'gem', 'composer']) + CommandKnowledge( + executable: c, + category: _pm, + description: 'Package manager.', + subcommands: const [_userInstall, _publish], + ), + for (final c in const ['pip', 'pip3']) + CommandKnowledge( + executable: c, + category: _pm, + description: 'Python package installer.', + subcommands: const [_userInstall], + ), + for (final c in const ['cargo', 'go']) + CommandKnowledge( + executable: c, + category: _pm, + description: 'Language toolchain / package manager.', + // These also build and run code. + baseCapabilities: const {CommandCapability.executePrograms}, + subcommands: const [_userInstall, _publish], + ), + + // --- system managers (also reconfigure the system) --- + for (final c in const [ + 'apt', + 'apt-get', + 'brew', + 'dnf', + 'yum', + 'pacman', + 'apk', + 'zypper', + 'choco', + 'winget', + 'scoop', + ]) + CommandKnowledge( + executable: c, + category: _pm, + description: 'System package manager.', + subcommands: const [ + SubcommandRule( + { + 'install', + 'update', + 'upgrade', + 'add', + 'fetch', + '-s', + 'remove', + 'erase', + 'reinstall', + }, + { + CommandCapability.networkRead, + CommandCapability.writeFilesystem, + CommandCapability.systemConfiguration, + }, + risk: SecurityLevel.mediumRisk, + description: 'Installs/updates system packages.', + ), + ], + ), + ]; +} diff --git a/lib/src/capabilities/knowledge/plugins/process_knowledge.dart b/lib/src/capabilities/knowledge/plugins/process_knowledge.dart new file mode 100644 index 0000000..9d2a6ee --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/process_knowledge.dart @@ -0,0 +1,37 @@ +import '../../capability.dart'; +import '../command_knowledge.dart'; +import '../command_knowledge_plugin.dart'; +import 'knowledge_builders.dart'; + +/// Knowledge about process inspection and control commands. +final class ProcessKnowledge implements CommandKnowledgePlugin { + /// Creates the process knowledge plugin. + const ProcessKnowledge(); + + @override + String get name => 'process'; + + @override + List get entries => simpleEntries( + const [ + 'kill', + 'killall', + 'pkill', + 'ps', + 'top', + 'htop', + 'renice', + 'taskkill', + 'tasklist', + 'get-process', + 'stop-process', + 'pgrep', + 'jobs', + 'bg', + 'fg', + 'wait', + ], + KnowledgeCategory.process, + const {CommandCapability.processManagement}, + ); +} diff --git a/lib/src/capabilities/knowledge/plugins/shell_knowledge.dart b/lib/src/capabilities/knowledge/plugins/shell_knowledge.dart new file mode 100644 index 0000000..ca42954 --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/shell_knowledge.dart @@ -0,0 +1,110 @@ +import '../../capability.dart'; +import '../command_knowledge.dart'; +import '../command_knowledge_plugin.dart'; +import 'knowledge_builders.dart'; + +/// Knowledge about shells, language interpreters/build tools, and the wrapper +/// commands that re-dispatch to another command in their arguments. +final class ShellKnowledge implements CommandKnowledgePlugin { + /// Creates the shell knowledge plugin. + const ShellKnowledge(); + + @override + String get name => 'shell'; + + @override + List get entries => [ + // --- shells & interpreters & build tools (execute code) --- + ...simpleEntries( + const [ + 'bash', + 'sh', + 'zsh', + 'dash', + 'ksh', + 'fish', + 'csh', + 'tcsh', + 'python', + 'python3', + 'node', + 'ruby', + 'perl', + 'php', + 'java', + 'npx', + 'make', + 'gcc', + 'clang', + 'cc', + 'eval', + 'source', + 'cmd', + 'powershell', + 'pwsh', + 'deno', + 'dotnet', + 'mvn', + 'gradle', + 'rake', + 'lua', + 'osascript', + 'rscript', + 'swift', + 'kotlin', + 'scala', + 'invoke-expression', + 'iex', + ], + KnowledgeCategory.interpreter, + const {CommandCapability.executePrograms}, + ), + + // --- privilege-escalation wrappers --- + for (final w in const ['sudo', 'su', 'doas', 'pkexec', 'runas']) + CommandKnowledge( + executable: w, + category: KnowledgeCategory.shell, + description: 'Runs a command with elevated privileges.', + baseCapabilities: const {CommandCapability.privilegeEscalation}, + wrapper: const WrapperSpec(), + ), + + // --- plain command wrappers --- + for (final w in const [ + 'xargs', + 'time', + 'nohup', + 'timeout', + 'watch', + 'command', + 'exec', + 'builtin', + 'stdbuf', + ]) + CommandKnowledge( + executable: w, + category: KnowledgeCategory.shell, + description: 'Wraps and executes another command.', + wrapper: const WrapperSpec(), + ), + + // `nice` both adjusts scheduling priority and wraps a command. + const CommandKnowledge( + executable: 'nice', + category: KnowledgeCategory.process, + description: 'Runs a command at an adjusted scheduling priority.', + baseCapabilities: {CommandCapability.processManagement}, + wrapper: WrapperSpec(), + ), + + // --- env: wrapper that also skips NAME=VALUE assignments --- + const CommandKnowledge( + executable: 'env', + category: KnowledgeCategory.environment, + description: 'Runs a command in a modified environment.', + baseCapabilities: {CommandCapability.environmentAccess}, + wrapper: WrapperSpec(skipAssignments: true), + ), + ]; +} diff --git a/lib/src/capabilities/knowledge/plugins/system_config_knowledge.dart b/lib/src/capabilities/knowledge/plugins/system_config_knowledge.dart new file mode 100644 index 0000000..edf377b --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/system_config_knowledge.dart @@ -0,0 +1,48 @@ +import '../../capability.dart'; +import '../command_knowledge.dart'; +import '../command_knowledge_plugin.dart'; +import 'knowledge_builders.dart'; + +/// Knowledge about commands that change system or security configuration. +final class SystemConfigKnowledge implements CommandKnowledgePlugin { + /// Creates the system-configuration knowledge plugin. + const SystemConfigKnowledge(); + + @override + String get name => 'system'; + + @override + List get entries => simpleEntries( + const [ + 'chmod', + 'chown', + 'chgrp', + 'chattr', + 'mount', + 'umount', + 'useradd', + 'userdel', + 'usermod', + 'groupadd', + 'passwd', + 'systemctl', + 'service', + 'launchctl', + 'setfacl', + 'sysctl', + 'reg', + 'netsh', + 'defaults', + 'crontab', + 'iptables', + 'ufw', + 'firewall-cmd', + 'set-itemproperty', + 'new-service', + 'set-acl', + 'setx', + ], + KnowledgeCategory.system, + const {CommandCapability.systemConfiguration}, + ); +} diff --git a/lib/src/capabilities/knowledge/plugins/windows_knowledge.dart b/lib/src/capabilities/knowledge/plugins/windows_knowledge.dart new file mode 100644 index 0000000..302b296 --- /dev/null +++ b/lib/src/capabilities/knowledge/plugins/windows_knowledge.dart @@ -0,0 +1,67 @@ +import '../../capability.dart'; +import '../command_knowledge.dart'; +import '../command_knowledge_plugin.dart'; +import 'knowledge_builders.dart'; + +/// Knowledge about Windows-specific commands: `cmd` builtins, PowerShell +/// cmdlets and system tools. +/// +/// Cross-platform Windows aliases that already appear in other plugins (`del`, +/// `copy`, `move`, `xcopy`, `robocopy`, `dir`, `type`, `reg`, `netsh`, +/// `set-itemproperty`, …) are owned by those plugins; this plugin adds the +/// commands unique to Windows. +final class WindowsKnowledge implements CommandKnowledgePlugin { + /// Creates the Windows knowledge plugin. + const WindowsKnowledge(); + + @override + String get name => 'windows'; + + static const _win = {CommandPlatform.windows}; + + @override + List get entries => [ + // --- read-only inspection --- + ...simpleEntries( + const [ + 'where', + 'findstr', + 'systeminfo', + 'ver', + 'get-item', + 'get-itemproperty', + 'select-string', + ], + KnowledgeCategory.filesystem, + const {CommandCapability.readFilesystem}, + platforms: _win, + ), + + // --- system configuration --- + ...simpleEntries( + const [ + 'sc', + 'icacls', + 'takeown', + 'attrib', + 'wmic', + 'bcdedit', + 'cacls', + 'set-service', + 'new-item', + 'set-localuser', + ], + KnowledgeCategory.system, + const {CommandCapability.systemConfiguration}, + platforms: _win, + ), + + // --- process management --- + ...simpleEntries( + const ['wevtutil'], + KnowledgeCategory.process, + const {CommandCapability.processManagement}, + platforms: _win, + ), + ]; +} diff --git a/lib/src/security/detectors/knowledge_risk_detector.dart b/lib/src/security/detectors/knowledge_risk_detector.dart new file mode 100644 index 0000000..5db6bfe --- /dev/null +++ b/lib/src/security/detectors/knowledge_risk_detector.dart @@ -0,0 +1,57 @@ +import '../../capabilities/command_knowledge_base.dart'; +import '../security_detector.dart'; +import '../security_finding.dart'; +import '../security_level.dart'; + +/// Surfaces the risk hints carried by the [CommandKnowledgeBase] as security +/// findings. +/// +/// Each invocation is looked up in the knowledge base; when its aggregated risk +/// exceeds [SecurityLevel.safe], a finding is emitted with the matched rule's +/// explanation (for example, a `git push --force`). +/// +/// This detector is **opt-in**: it is *not* part of +/// `SecurityAnalyzer.defaultDetectors`, because the built-in destructive, +/// privilege-escalation and remote-exec detectors already cover the genuinely +/// dangerous cases and most knowledge entries carry no elevated risk. Add it +/// explicitly to enable knowledge-driven findings: +/// +/// ```dart +/// final analyzer = SecurityAnalyzer(detectors: [ +/// ...SecurityAnalyzer.defaultDetectors, +/// const KnowledgeRiskDetector(), +/// ]); +/// ``` +final class KnowledgeRiskDetector extends SecurityDetector { + /// Creates the detector, optionally with a custom [knowledgeBase]. + KnowledgeRiskDetector({CommandKnowledgeBase? knowledgeBase}) + : _knowledgeBase = knowledgeBase ?? CommandKnowledgeBase.standard(); + + final CommandKnowledgeBase _knowledgeBase; + + @override + String get code => 'knowledge-risk'; + + @override + List detect(SecurityContext context) { + final findings = []; + for (final inv in context.invocations) { + if (inv.executable.isEmpty) continue; + final exe = context.normalizedExecutable(inv); + final result = _knowledgeBase.analyze(exe, inv.arguments); + if (result.risk.isAtLeast(SecurityLevel.lowRisk)) { + final detail = result.notes.isNotEmpty + ? ' (${result.notes.join('; ')})' + : ''; + findings.add( + SecurityFinding( + level: result.risk, + message: 'Elevated-risk command "$exe"$detail.', + code: code, + ), + ); + } + } + return findings; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 71f1e6e..482e3cf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: >- Security-first command-line analysis: parse, normalize, classify, analyze and policy-validate shell commands into ALLOW / REVIEW / DENY decisions without ever executing them. Built for AI agents and sandboxed executors. -version: 1.0.0 +version: 2.0.0 homepage: https://github.com/OmnyGrid/command_shield repository: https://github.com/OmnyGrid/command_shield issue_tracker: https://github.com/OmnyGrid/command_shield/issues diff --git a/test/unit/capabilities/capability_test.dart b/test/unit/capabilities/capability_test.dart index 132cea5..afa9a4d 100644 --- a/test/unit/capabilities/capability_test.dart +++ b/test/unit/capabilities/capability_test.dart @@ -133,9 +133,15 @@ void main() { test('knowledge base is extensible', () { final kb = CommandKnowledgeBase( - extraExecutableCapabilities: { - 'frobnicate': {CommandCapability.networkWrite}, - }, + plugins: const [ + ListKnowledgePlugin('custom', [ + CommandKnowledge( + executable: 'frobnicate', + category: KnowledgeCategory.other, + baseCapabilities: {CommandCapability.networkWrite}, + ), + ]), + ], ); final custom = CapabilityDetector(knowledgeBase: kb); expect( diff --git a/test/unit/capabilities/knowledge_base_test.dart b/test/unit/capabilities/knowledge_base_test.dart index 471eda9..5b9928c 100644 --- a/test/unit/capabilities/knowledge_base_test.dart +++ b/test/unit/capabilities/knowledge_base_test.dart @@ -130,4 +130,106 @@ void main() { expect(caps('zzznotacommand', const []), isEmpty); }); }); + + group('CommandKnowledgeBase.analyze', () { + test('unknown command is not known and is safe', () { + final r = kb.analyze('zzznotacommand', const []); + expect(r.isKnown, isFalse); + expect(r.knowledge, isNull); + expect(r.capabilities, isEmpty); + expect(r.risk, SecurityLevel.safe); + }); + + test('known command exposes its knowledge entry and category', () { + final r = kb.analyze('git', const ['status']); + expect(r.isKnown, isTrue); + expect(r.knowledge!.executable, 'git'); + expect(r.knowledge!.category, KnowledgeCategory.versionControl); + }); + + test('git force push raises risk to medium with a note', () { + final r = kb.analyze('git', const ['push', '--force', 'origin', 'main']); + expect(r.capabilities, contains(CommandCapability.networkWrite)); + expect(r.risk, SecurityLevel.mediumRisk); + expect(r.notes, isNotEmpty); + }); + + test('non-push force flag does not raise risk', () { + final r = kb.analyze('git', const ['checkout', '-f', 'main']); + expect(r.risk, SecurityLevel.safe); + expect(r.capabilities, isNot(contains(CommandCapability.networkWrite))); + }); + + test('package install is flagged medium risk', () { + expect( + kb.analyze('npm', const ['install', 'x']).risk, + SecurityLevel.mediumRisk, + ); + }); + }); + + group('subcommand matching skips leading flags', () { + test('git --no-pager push is still a push', () { + expect( + caps('git', ['--no-pager', 'push']), + contains(CommandCapability.networkWrite), + ); + }); + + test('docker --debug push is still a push', () { + expect( + caps('docker', ['--debug', 'push', 'img']), + contains(CommandCapability.networkWrite), + ); + }); + }); + + group('newly covered commands', () { + test('dart pub get reads network and writes files', () { + final c = caps('dart', ['pub', 'get']); + expect(c, contains(CommandCapability.networkRead)); + expect(c, contains(CommandCapability.writeFilesystem)); + }); + + test('dart pub publish writes to the network', () { + expect( + caps('dart', ['pub', 'publish']), + contains(CommandCapability.networkWrite), + ); + }); + + test('flutter run executes code', () { + expect( + caps('flutter', ['run']), + contains(CommandCapability.executePrograms), + ); + }); + + test('tar reads and writes the filesystem', () { + final c = caps('tar', ['-xzf', 'a.tgz']); + expect(c, contains(CommandCapability.readFilesystem)); + expect(c, contains(CommandCapability.writeFilesystem)); + }); + + test('docker push writes to the network', () { + expect( + caps('docker', ['push', 'img']), + contains(CommandCapability.networkWrite), + ); + }); + + test('gh accesses the network', () { + expect( + caps('gh', ['pr', 'create']), + contains(CommandCapability.networkRead), + ); + }); + + test('cargo build executes programs', () { + expect( + caps('cargo', ['build']), + contains(CommandCapability.executePrograms), + ); + }); + }); } diff --git a/test/unit/capabilities/knowledge_plugins_test.dart b/test/unit/capabilities/knowledge_plugins_test.dart new file mode 100644 index 0000000..3a2c52e --- /dev/null +++ b/test/unit/capabilities/knowledge_plugins_test.dart @@ -0,0 +1,122 @@ +import 'package:command_shield/command_shield.dart'; +import 'package:test/test.dart'; + +void main() { + group('plugin composition', () { + test('default base loads all built-in plugins', () { + final kb = CommandKnowledgeBase.standard(); + // A representative command from each domain resolves. + for (final exe in const [ + 'ls', + 'tar', + 'bash', + 'printenv', + 'ps', + 'chmod', + 'curl', + 'docker', + 'npm', + 'dart', + 'git', + 'icacls', + ]) { + expect(kb.knowledgeFor(exe), isNotNull, reason: '$exe should be known'); + } + }); + + test('includeDefaults:false yields only supplied plugins', () { + final kb = CommandKnowledgeBase( + includeDefaults: false, + plugins: const [GitKnowledge()], + ); + expect(kb.knowledgeFor('git'), isNotNull); + expect(kb.knowledgeFor('ls'), isNull); + }); + + test('later plugins override earlier ones for the same executable', () { + final kb = CommandKnowledgeBase( + plugins: const [ + ListKnowledgePlugin('override', [ + CommandKnowledge( + executable: 'git', + category: KnowledgeCategory.other, + baseCapabilities: {CommandCapability.executePrograms}, + ), + ]), + ], + ); + expect(kb.knowledgeFor('git')!.category, KnowledgeCategory.other); + }); + + test('aliases register additional lookup keys', () { + final kb = CommandKnowledgeBase( + includeDefaults: false, + plugins: const [ + ListKnowledgePlugin('aliased', [ + CommandKnowledge( + executable: 'mytool', + aliases: {'mt'}, + category: KnowledgeCategory.other, + baseCapabilities: {CommandCapability.readFilesystem}, + ), + ]), + ], + ); + expect( + kb.capabilitiesFor('mt', const []), + contains(CommandCapability.readFilesystem), + ); + }); + + test('every entry name is unique across default plugins', () { + final kb = CommandKnowledgeBase.standard(); + // allKnowledge dedupes entries; confirm there are no surprises by + // spot-checking a wrapper still recurses. + expect( + kb.capabilitiesFor('sudo', const ['rm', '-rf', 'x']), + containsAll(const [ + CommandCapability.privilegeEscalation, + CommandCapability.deleteFilesystem, + ]), + ); + }); + }); + + group('KnowledgeRiskDetector (opt-in)', () { + SecurityReport analyze(String raw) { + final ast = ParserFactory.forSyntax(CommandSyntax.bash).parse(raw).ast!; + final analyzer = SecurityAnalyzer( + detectors: [ + ...SecurityAnalyzer.defaultDetectors, + KnowledgeRiskDetector(), + ], + ); + return analyzer.analyze( + SecurityContext( + raw: raw, + syntax: CommandSyntax.bash, + ast: ast, + normalizer: Normalizer.standard(), + ), + ); + } + + test('emits a finding for a force push', () { + final report = analyze('git push --force origin main'); + expect(report.findings.any((f) => f.code == 'knowledge-risk'), isTrue); + expect(report.level.isAtLeast(SecurityLevel.mediumRisk), isTrue); + }); + + test('stays silent for a safe command', () { + final report = analyze('git status'); + expect(report.findings.any((f) => f.code == 'knowledge-risk'), isFalse); + }); + + test('is not enabled in the default detector set', () { + expect( + SecurityAnalyzer.defaultDetectors.whereType(), + isEmpty, + ); + }); + }); +}