From 6ff1286d31729bc1f7daf74f653c33a2a9a5a778 Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Mon, 29 Jun 2026 05:44:01 -0300 Subject: [PATCH] feat(knowledge): treat informational invocations as safe (1.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an informational-form safety gate so commands that are risky-by-default (dart, flutter, node, python, go, cargo, …) classify as read-only/safe when *every* argument is a purely informational token (--version/--help/--usage, plus per-command forms like java -version, python -V, go version/env). - New top-level `defaultInformationalTokens` (global) and per-command `CommandKnowledge.informationalTokens` field. - Gate runs at the top of `_collect`, before base caps/risk and at every recursion depth, so wrapped forms (`sudo node --version`) are handled. - Case-sensitive matching (-V ≠ -v); a payload token defeats the gate (`dart --version x.dart`, `go env -w K=V` still execute). - Adds a dedicated test group; bumps version to 1.3.0 and updates docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 28 ++++ doc/architecture.md | 5 +- .../capabilities/command_knowledge_base.dart | 31 +++++ .../knowledge/command_knowledge.dart | 24 ++++ .../knowledge/plugins/knowledge_builders.dart | 2 + .../plugins/package_manager_knowledge.dart | 28 ++-- .../knowledge/plugins/shell_knowledge.dart | 54 ++++++-- pubspec.yaml | 2 +- .../capabilities/knowledge_base_test.dart | 123 ++++++++++++++++++ 9 files changed, 277 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a94239b..6c51f2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +## 1.3.0 + +### Added + +- **Informational-form safety gate.** When *every* argument of a known command is + a purely informational token, the invocation is recognised as read-only and + safe — even for execute-by-default tools. So `dart --version`, + `flutter --version`, `node --version`, `python --version`, `go version`, + `java -version`, etc. now classify as `readFilesystem`/`safe` instead of + `executePrograms`. A new top-level `defaultInformationalTokens` + (`--version`, `--help`, `--usage`) covers all commands globally; individual + entries opt in to extra, tool-specific forms via the new + `CommandKnowledge.informationalTokens` field (e.g. java `-version`, + python `-V`, node/php/ruby `-v`, perl `-v`/`-V`, deno/cargo `-V`, + go `version`/`env`, dotnet `--info`/`--list-sdks`/`--list-runtimes`). + Matching is case-sensitive (`-V` ≠ `-v`) and requires the whole invocation to + be informational, so a payload defeats the gate: `dart --version script.dart` + and `go env -w K=V` still report `executePrograms`. The gate runs at every + recursion depth, so wrapped forms like `sudo node --version` are handled + (and `sudo` still reports `privilegeEscalation`). + +### Changed + +- Bare `--version`/`--help`/`--usage` (and the per-command short forms above) on + execute-by-default tools (`dart`, `flutter`, `node`, `python`, `go`, `cargo`, + …) now classify as read-only/safe instead of `executePrograms`. `npm --version` + moves from an empty capability set to `readFilesystem` (risk stays `safe`). + ## 1.2.0 ### Added diff --git a/doc/architecture.md b/doc/architecture.md index 83172bb..10f0d1e 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -49,7 +49,10 @@ sub-commands (`git push` → networkWrite vs `git status` → readFilesystem), upload flags, and **wrapper commands** (`sudo`, `env`, `xargs`, …) whose wrapped program's capabilities are attributed to the invocation. Structural features add capabilities too: redirections imply read/write, substitutions imply execution, -env references imply environment access. +env references imply environment access. Conversely, purely informational +invocations — where every argument is a version/help token such as +`dart --version`, `node --version` or `--help` — are recognised and treated as +read-only even for execute-by-default tools. ## 4. Effects (`src/classification/`) diff --git a/lib/src/capabilities/command_knowledge_base.dart b/lib/src/capabilities/command_knowledge_base.dart index 4327978..4e581e6 100644 --- a/lib/src/capabilities/command_knowledge_base.dart +++ b/lib/src/capabilities/command_knowledge_base.dart @@ -101,6 +101,23 @@ final class CommandKnowledgeBase { required int depth, }) { final entry = _table[exe]; + + // Informational-form gate: when EVERY argument is a purely informational + // token (e.g. `--version`, `--help`), the invocation only prints metadata + // and executes nothing — regardless of how risky the command is by default. + // Requiring every token to be informational is the safety guarantee: + // `dart --version` is suppressed, but `dart --version x.dart` and + // `go env -w K=V` fall through to normal analysis. This runs before base + // capabilities/risk are applied and at every recursion depth, so wrapped + // forms (`sudo node --version`) are handled too. + if (entry != null && args.isNotEmpty && _isInformationalOnly(entry, args)) { + match.add(CommandCapability.readFilesystem); + match.note( + 'Informational invocation ("${args.join(' ')}") prints metadata only.', + ); + return depth == 0 ? entry : null; + } + if (entry != null) { match.addAll(entry.baseCapabilities); match.raiseRisk(entry.baseRisk); @@ -179,6 +196,20 @@ final class CommandKnowledgeBase { return _Wrapped(args[i].toLowerCase(), args.sublist(i + 1)); } + /// Whether [args] consists entirely of informational tokens (the global + /// [defaultInformationalTokens] plus any [CommandKnowledge.informationalTokens] + /// the matched [entry] declares). Case-sensitive. + static bool _isInformationalOnly(CommandKnowledge entry, List args) { + if (entry.informationalTokens.isEmpty) { + return args.every(defaultInformationalTokens.contains); + } + return args.every( + (a) => + defaultInformationalTokens.contains(a) || + entry.informationalTokens.contains(a), + ); + } + static String? _firstNonFlag(List args) { for (final a in args) { if (!a.startsWith('-')) return a; diff --git a/lib/src/capabilities/knowledge/command_knowledge.dart b/lib/src/capabilities/knowledge/command_knowledge.dart index 1eb29d8..547a1b2 100644 --- a/lib/src/capabilities/knowledge/command_knowledge.dart +++ b/lib/src/capabilities/knowledge/command_knowledge.dart @@ -3,6 +3,21 @@ import 'package:meta/meta.dart' show immutable; import '../../security/security_level.dart'; import '../capability.dart'; +/// Argument tokens that, when they are the ONLY arguments present, make any +/// known command a purely informational invocation (it prints metadata and +/// executes nothing) and therefore safe — even for execute-by-default tools +/// such as `dart`, `node` or `python`. +/// +/// Deliberately limited to UNAMBIGUOUS long forms. Short forms like `-v` +/// (often "verbose"), `-V` and `-h` (often "human-readable"/"host") vary by +/// tool and are opted in per command via [CommandKnowledge.informationalTokens]. +/// Matching is CASE-SENSITIVE so `-V` and `-v` are never conflated. +const Set defaultInformationalTokens = { + '--version', + '--help', + '--usage', +}; + /// The broad domain a command belongs to. /// /// Categories are purely descriptive metadata; they do not affect capability @@ -294,6 +309,7 @@ final class CommandKnowledge { this.description, this.baseCapabilities = const {}, this.baseRisk = SecurityLevel.safe, + this.informationalTokens = const {}, this.subcommands = const [], this.argumentRules = const [], this.wrapper, @@ -322,6 +338,14 @@ final class CommandKnowledge { /// refinement raises it. final SecurityLevel baseRisk; + /// Extra tokens — beyond [defaultInformationalTokens] — that mark a purely + /// informational invocation of THIS command when they are the only arguments + /// present (e.g. java `-version`, python `-V`, go `version`/`env`). When the + /// whole invocation is informational the command is treated as read-only and + /// safe regardless of [baseCapabilities]/[baseRisk]. Matching is + /// case-sensitive. + final Set informationalTokens; + /// Rules keyed on the first non-flag argument (the sub-command). final List subcommands; diff --git a/lib/src/capabilities/knowledge/plugins/knowledge_builders.dart b/lib/src/capabilities/knowledge/plugins/knowledge_builders.dart index f6b50cd..c4d2c1b 100644 --- a/lib/src/capabilities/knowledge/plugins/knowledge_builders.dart +++ b/lib/src/capabilities/knowledge/plugins/knowledge_builders.dart @@ -11,6 +11,7 @@ List simpleEntries( KnowledgeCategory category, Set capabilities, { Set platforms = const {CommandPlatform.cross}, + Set informationalTokens = const {}, }) => [ for (final n in names) CommandKnowledge( @@ -18,5 +19,6 @@ List simpleEntries( category: category, platforms: platforms, baseCapabilities: capabilities, + informationalTokens: informationalTokens, ), ]; diff --git a/lib/src/capabilities/knowledge/plugins/package_manager_knowledge.dart b/lib/src/capabilities/knowledge/plugins/package_manager_knowledge.dart index c936580..1a10ffd 100644 --- a/lib/src/capabilities/knowledge/plugins/package_manager_knowledge.dart +++ b/lib/src/capabilities/knowledge/plugins/package_manager_knowledge.dart @@ -112,15 +112,25 @@ final class PackageManagerKnowledge implements CommandKnowledgePlugin { description: 'Uploads Python packages to PyPI.', baseCapabilities: {CommandCapability.networkWrite}, ), - 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], - ), + // cargo and go also build and run code, but each has its own informational + // forms: cargo `-V`, and go's positional `version`/`env` sub-commands + // (`go env -w K=V` falls through via the non-informational `-w`). + const CommandKnowledge( + executable: 'cargo', + category: _pm, + description: 'Rust toolchain / package manager.', + baseCapabilities: {CommandCapability.executePrograms}, + subcommands: [_userInstall, _publish], + informationalTokens: {'-V'}, + ), + const CommandKnowledge( + executable: 'go', + category: _pm, + description: 'Go toolchain / package manager.', + baseCapabilities: {CommandCapability.executePrograms}, + subcommands: [_userInstall, _publish], + informationalTokens: {'version', 'env'}, + ), // --- system managers (also reconfigure the system) --- for (final c in const [ diff --git a/lib/src/capabilities/knowledge/plugins/shell_knowledge.dart b/lib/src/capabilities/knowledge/plugins/shell_knowledge.dart index d12d5d4..fcd0108 100644 --- a/lib/src/capabilities/knowledge/plugins/shell_knowledge.dart +++ b/lib/src/capabilities/knowledge/plugins/shell_knowledge.dart @@ -25,13 +25,6 @@ final class ShellKnowledge implements CommandKnowledgePlugin { 'fish', 'csh', 'tcsh', - 'python', - 'python3', - 'node', - 'ruby', - 'perl', - 'php', - 'java', 'npx', 'make', 'gcc', @@ -42,8 +35,6 @@ final class ShellKnowledge implements CommandKnowledgePlugin { 'cmd', 'powershell', 'pwsh', - 'deno', - 'dotnet', 'mvn', 'gradle', 'rake', @@ -119,6 +110,51 @@ final class ShellKnowledge implements CommandKnowledgePlugin { const {CommandCapability.executePrograms}, ), + // --- interpreters with extra, unambiguous informational version flags --- + // (the global `--version`/`--help`/`--usage` gate covers the rest). + ...simpleEntries( + const ['node', 'php'], + KnowledgeCategory.interpreter, + const {CommandCapability.executePrograms}, + informationalTokens: const {'-v'}, + ), + ...simpleEntries( + const ['ruby'], + KnowledgeCategory.interpreter, + const {CommandCapability.executePrograms}, + informationalTokens: const {'-v'}, + ), + ...simpleEntries( + const ['python', 'python3'], + KnowledgeCategory.interpreter, + const {CommandCapability.executePrograms}, + informationalTokens: const {'-V'}, + ), + ...simpleEntries( + const ['perl'], + KnowledgeCategory.interpreter, + const {CommandCapability.executePrograms}, + informationalTokens: const {'-v', '-V'}, + ), + ...simpleEntries( + const ['deno'], + KnowledgeCategory.interpreter, + const {CommandCapability.executePrograms}, + informationalTokens: const {'-V'}, + ), + ...simpleEntries( + const ['java'], + KnowledgeCategory.interpreter, + const {CommandCapability.executePrograms}, + informationalTokens: const {'-version'}, + ), + ...simpleEntries( + const ['dotnet'], + KnowledgeCategory.interpreter, + const {CommandCapability.executePrograms}, + informationalTokens: const {'--info', '--list-sdks', '--list-runtimes'}, + ), + // --- privilege-escalation wrappers --- for (final w in const ['sudo', 'su', 'doas', 'pkexec', 'runas']) CommandKnowledge( diff --git a/pubspec.yaml b/pubspec.yaml index ab7cf36..699fa01 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.2.0 +version: 1.3.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/knowledge_base_test.dart b/test/unit/capabilities/knowledge_base_test.dart index 6443462..d7e4863 100644 --- a/test/unit/capabilities/knowledge_base_test.dart +++ b/test/unit/capabilities/knowledge_base_test.dart @@ -665,4 +665,127 @@ void main() { ); }); }); + + group('informational-form gate', () { + test('execute-by-default tools are safe with --version', () { + for (final exe in const [ + 'dart', + 'flutter', + 'node', + 'python', + 'go', + 'cargo', + 'deno', + 'gcc', + ]) { + final r = kb.analyze(exe, const ['--version']); + expect(r.risk, SecurityLevel.safe, reason: exe); + expect( + r.capabilities, + isNot(contains(CommandCapability.executePrograms)), + reason: exe, + ); + expect( + r.capabilities, + contains(CommandCapability.readFilesystem), + reason: exe, + ); + } + }); + + test('--help and --usage are also informational', () { + expect( + caps('dart', const ['--help']), + isNot(contains(CommandCapability.executePrograms)), + ); + expect(kb.analyze('flutter', const ['--help']).risk, SecurityLevel.safe); + expect( + caps('node', const ['--usage']), + isNot(contains(CommandCapability.executePrograms)), + ); + }); + + test('per-command short forms: java -version, python -V, node -v', () { + expect( + caps('java', const ['-version']), + isNot(contains(CommandCapability.executePrograms)), + ); + expect( + caps('python', const ['-V']), + isNot(contains(CommandCapability.executePrograms)), + ); + expect( + caps('node', const ['-v']), + isNot(contains(CommandCapability.executePrograms)), + ); + }); + + test('go version and go env (alone) are informational', () { + expect( + caps('go', const ['version']), + isNot(contains(CommandCapability.executePrograms)), + ); + expect( + caps('go', const ['env']), + isNot(contains(CommandCapability.executePrograms)), + ); + }); + + test('a non-informational token defeats the gate', () { + // `dart --version