Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion doc/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`)

Expand Down
31 changes: 31 additions & 0 deletions lib/src/capabilities/command_knowledge_base.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<String> 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<String> args) {
for (final a in args) {
if (!a.startsWith('-')) return a;
Expand Down
24 changes: 24 additions & 0 deletions lib/src/capabilities/knowledge/command_knowledge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> defaultInformationalTokens = <String>{
'--version',
'--help',
'--usage',
};

/// The broad domain a command belongs to.
///
/// Categories are purely descriptive metadata; they do not affect capability
Expand Down Expand Up @@ -294,6 +309,7 @@ final class CommandKnowledge {
this.description,
this.baseCapabilities = const <CommandCapability>{},
this.baseRisk = SecurityLevel.safe,
this.informationalTokens = const <String>{},
this.subcommands = const <SubcommandRule>[],
this.argumentRules = const <ArgumentRule>[],
this.wrapper,
Expand Down Expand Up @@ -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<String> informationalTokens;

/// Rules keyed on the first non-flag argument (the sub-command).
final List<SubcommandRule> subcommands;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ List<CommandKnowledge> simpleEntries(
KnowledgeCategory category,
Set<CommandCapability> capabilities, {
Set<CommandPlatform> platforms = const {CommandPlatform.cross},
Set<String> informationalTokens = const <String>{},
}) => [
for (final n in names)
CommandKnowledge(
executable: n,
category: category,
platforms: platforms,
baseCapabilities: capabilities,
informationalTokens: informationalTokens,
),
];
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
54 changes: 45 additions & 9 deletions lib/src/capabilities/knowledge/plugins/shell_knowledge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,6 @@ final class ShellKnowledge implements CommandKnowledgePlugin {
'fish',
'csh',
'tcsh',
'python',
'python3',
'node',
'ruby',
'perl',
'php',
'java',
'npx',
'make',
'gcc',
Expand All @@ -42,8 +35,6 @@ final class ShellKnowledge implements CommandKnowledgePlugin {
'cmd',
'powershell',
'pwsh',
'deno',
'dotnet',
'mvn',
'gradle',
'rake',
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions test/unit/capabilities/knowledge_base_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script>` still executes.
expect(
caps('dart', const ['--version', 'script.dart']),
contains(CommandCapability.executePrograms),
);
// `go env -w ...` writes config — must NOT be suppressed.
expect(
caps('go', const ['env', '-w', 'GOFLAGS=-mod=mod']),
contains(CommandCapability.executePrograms),
);
});

test('case sensitivity: python -v (verbose) is not informational', () {
// For python, `-V` prints the version but `-v` is verbose tracing.
expect(
caps('python', const ['-v']),
contains(CommandCapability.executePrograms),
);
});

test('npm --version stays safe (now read-only)', () {
final r = kb.analyze('npm', const ['--version']);
expect(r.risk, SecurityLevel.safe);
expect(
r.capabilities,
isNot(contains(CommandCapability.executePrograms)),
);
expect(r.capabilities, contains(CommandCapability.readFilesystem));
});

test('bare interpreter (no args) still executes by default', () {
expect(
caps('dart', const []),
contains(CommandCapability.executePrograms),
);
});

test('wrapped informational form: sudo node --version', () {
final r = kb.analyze('sudo', const ['node', '--version']);
expect(
r.capabilities,
isNot(contains(CommandCapability.executePrograms)),
);
// sudo itself still escalates privilege.
expect(r.capabilities, contains(CommandCapability.privilegeEscalation));
});

test('command -v lookup still works (gate does not interfere)', () {
final lookup = kb.analyze('command', const ['-v', 'rm']);
expect(lookup.capabilities, contains(CommandCapability.readFilesystem));
expect(
lookup.capabilities,
isNot(contains(CommandCapability.deleteFilesystem)),
);
});
});
}