watchllm-kernel 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- watchllm_kernel-0.1.0/PKG-INFO +138 -0
- watchllm_kernel-0.1.0/README.md +114 -0
- watchllm_kernel-0.1.0/pyproject.toml +48 -0
- watchllm_kernel-0.1.0/setup.cfg +4 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/__init__.py +23 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/__main__.py +4 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/cli.py +214 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/config_loader.py +43 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/engine.py +115 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/models.py +100 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/parser.py +128 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/reporting.py +186 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/rules/__init__.py +17 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/rules/_ast_utils.py +96 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/rules/auth_flow.py +300 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/rules/boundary.py +231 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/rules/forbidden_imports.py +202 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel/rules/secrets.py +190 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel.egg-info/PKG-INFO +138 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel.egg-info/SOURCES.txt +34 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel.egg-info/dependency_links.txt +1 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel.egg-info/entry_points.txt +2 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel.egg-info/requires.txt +4 -0
- watchllm_kernel-0.1.0/src/watchllm_kernel.egg-info/top_level.txt +1 -0
- watchllm_kernel-0.1.0/tests/test_auth_flow_rule.py +93 -0
- watchllm_kernel-0.1.0/tests/test_benchmarks.py +82 -0
- watchllm_kernel-0.1.0/tests/test_boundary_rule.py +108 -0
- watchllm_kernel-0.1.0/tests/test_cli.py +173 -0
- watchllm_kernel-0.1.0/tests/test_cli_contract.py +146 -0
- watchllm_kernel-0.1.0/tests/test_e2e.py +212 -0
- watchllm_kernel-0.1.0/tests/test_engine.py +153 -0
- watchllm_kernel-0.1.0/tests/test_fixture_corpus.py +57 -0
- watchllm_kernel-0.1.0/tests/test_forbidden_import_rule.py +115 -0
- watchllm_kernel-0.1.0/tests/test_models.py +115 -0
- watchllm_kernel-0.1.0/tests/test_parser.py +94 -0
- watchllm_kernel-0.1.0/tests/test_reporting.py +132 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: watchllm-kernel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic local runtime governance kernel for autonomous coding agents.
|
|
5
|
+
Author: WatchLLM
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: watchllm,kernel,runtime-governance,agent-governance,static-analysis
|
|
8
|
+
Classifier: Development Status :: 1 - Planning
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: tree-sitter>=0.21.0
|
|
21
|
+
Requires-Dist: tree-sitter-javascript>=0.21.0
|
|
22
|
+
Requires-Dist: tree-sitter-typescript>=0.21.0
|
|
23
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
24
|
+
|
|
25
|
+
# WatchLLM Kernel
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
|
|
29
|
+
░ ░░░░ ░░░ ░░░ ░░░ ░░░ ░░░░ ░░ ░░░░░░░░ ░░░░░░░░ ░░░░ ░
|
|
30
|
+
▒ ▒ ▒ ▒▒ ▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒ ▒▒ ▒▒▒▒ ▒▒ ▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒ ▒▒ ▒
|
|
31
|
+
▓ ▓▓ ▓▓▓▓ ▓▓▓▓▓ ▓▓▓▓▓ ▓▓▓▓▓▓▓▓ ▓▓ ▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓ ▓
|
|
32
|
+
█ ██ ██ █████ █████ ████ ██ ████ ██ ████████ ████████ █ █ █
|
|
33
|
+
█ ████ ██ ████ █████ ██████ ███ ████ ██ ██ ██ ████ █
|
|
34
|
+
████████████████████████████████████████████████████████████████████████████████
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Deterministic local write-path governance kernel for autonomous coding agents.
|
|
38
|
+
|
|
39
|
+
## Current status
|
|
40
|
+
|
|
41
|
+
Task 14 complete — core model layer, parser abstraction, fixture corpus, rule implementations, deterministic decision engine, CLI evaluation interface, end-to-end regression tests, baseline performance benchmarks, and local blocked-event reporting exist. Save-path editor integration is not implemented yet.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
python -m pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
watchllm-kernel --help
|
|
53
|
+
python -m watchllm_kernel --help
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Fixture corpus
|
|
57
|
+
|
|
58
|
+
Rule evidence fixtures live under `tests/fixtures/rules/`.
|
|
59
|
+
|
|
60
|
+
Each MVP rule category has a minimal `pass/` and `fail/` fixture set:
|
|
61
|
+
|
|
62
|
+
- `secrets`
|
|
63
|
+
- `forbidden_imports`
|
|
64
|
+
- `boundary`
|
|
65
|
+
- `auth_flow`
|
|
66
|
+
|
|
67
|
+
These fixtures are rule evidence examples and are used by rule-specific tests as each rule is implemented.
|
|
68
|
+
|
|
69
|
+
## Implemented rules
|
|
70
|
+
|
|
71
|
+
### Secret-literal rule
|
|
72
|
+
|
|
73
|
+
The secret-literal rule detects hardcoded credential patterns in assignment contexts and dangerous call contexts. It uses AST context to avoid flagging safe retrieval calls such as `process.env.STRIPE_SECRET` or `os.getenv("STRIPE_SECRET")`.
|
|
74
|
+
|
|
75
|
+
### Forbidden-import rule
|
|
76
|
+
|
|
77
|
+
The forbidden-import rule blocks dangerous imports such as `child_process` and disallowed relative traversal imports. It extracts ES module imports and CommonJS `require(...)` calls using AST traversal rather than raw text scanning.
|
|
78
|
+
|
|
79
|
+
### Boundary rule
|
|
80
|
+
|
|
81
|
+
The boundary rule checks AST-extracted import edges against a small declared boundary map. In the current policy, `auth` may import the public DB contract but must not import `db/internal` paths directly.
|
|
82
|
+
|
|
83
|
+
Circular dependency detection is explicitly deferred because Task 08 evaluates single-file import edges only, not a repository-wide import graph.
|
|
84
|
+
|
|
85
|
+
### Auth-flow rule
|
|
86
|
+
|
|
87
|
+
The auth-flow rule checks calls inside an exported `handler` function and requires an explicit auth guard before protected database operations such as `db.user.update(...)`.
|
|
88
|
+
|
|
89
|
+
Current Task 09 behaviour is intentionally narrow:
|
|
90
|
+
|
|
91
|
+
- mutation before auth returns `FAIL`
|
|
92
|
+
- auth before mutation returns `PASS`
|
|
93
|
+
- auth found only inside an ambiguous branch before mutation returns `INCONCLUSIVE`
|
|
94
|
+
|
|
95
|
+
Repository-wide control-flow analysis is not implemented yet.
|
|
96
|
+
|
|
97
|
+
## Decision engine
|
|
98
|
+
|
|
99
|
+
The decision engine runs a supplied ordered list of rules against one source buffer and reduces their rule results into one `KernelResult`.
|
|
100
|
+
|
|
101
|
+
In enforce mode, any rule failure produces `BLOCK`.
|
|
102
|
+
|
|
103
|
+
In shadow mode, rule failures are preserved in the result but the final decision remains `ALLOW`.
|
|
104
|
+
|
|
105
|
+
For Task 10, `INCONCLUSIVE` rule results are recorded but do not block.
|
|
106
|
+
|
|
107
|
+
## Benchmarks
|
|
108
|
+
|
|
109
|
+
Run the current Python kernel benchmark suite with:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
python benchmarks/run_benchmarks.py --iterations 50 --warmup 5 --json
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Benchmark baseline documentation lives in `docs/benchmarks/baseline.md`.
|
|
116
|
+
|
|
117
|
+
## Local violation reporting
|
|
118
|
+
|
|
119
|
+
Blocked evaluations are written locally as JSONL.
|
|
120
|
+
|
|
121
|
+
Default path:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
.watchllm/logs/violations.jsonl
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Override path:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
WATCHLLM_LOG_PATH=/tmp/watchllm-violations.jsonl python -m watchllm_kernel evaluate path/to/file.ts --json
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The reporting contract is documented in `docs/specs/reporting-contract.md`.
|
|
134
|
+
|
|
135
|
+
## Non‑goals (current state)
|
|
136
|
+
|
|
137
|
+
- No save-path editor integration yet
|
|
138
|
+
- No cloud dependency or network enforcement
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# WatchLLM Kernel
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
|
|
5
|
+
░ ░░░░ ░░░ ░░░ ░░░ ░░░ ░░░░ ░░ ░░░░░░░░ ░░░░░░░░ ░░░░ ░
|
|
6
|
+
▒ ▒ ▒ ▒▒ ▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒ ▒▒ ▒▒▒▒ ▒▒ ▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒ ▒▒ ▒
|
|
7
|
+
▓ ▓▓ ▓▓▓▓ ▓▓▓▓▓ ▓▓▓▓▓ ▓▓▓▓▓▓▓▓ ▓▓ ▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓ ▓
|
|
8
|
+
█ ██ ██ █████ █████ ████ ██ ████ ██ ████████ ████████ █ █ █
|
|
9
|
+
█ ████ ██ ████ █████ ██████ ███ ████ ██ ██ ██ ████ █
|
|
10
|
+
████████████████████████████████████████████████████████████████████████████████
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Deterministic local write-path governance kernel for autonomous coding agents.
|
|
14
|
+
|
|
15
|
+
## Current status
|
|
16
|
+
|
|
17
|
+
Task 14 complete — core model layer, parser abstraction, fixture corpus, rule implementations, deterministic decision engine, CLI evaluation interface, end-to-end regression tests, baseline performance benchmarks, and local blocked-event reporting exist. Save-path editor integration is not implemented yet.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python -m pip install -e .
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
watchllm-kernel --help
|
|
29
|
+
python -m watchllm_kernel --help
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Fixture corpus
|
|
33
|
+
|
|
34
|
+
Rule evidence fixtures live under `tests/fixtures/rules/`.
|
|
35
|
+
|
|
36
|
+
Each MVP rule category has a minimal `pass/` and `fail/` fixture set:
|
|
37
|
+
|
|
38
|
+
- `secrets`
|
|
39
|
+
- `forbidden_imports`
|
|
40
|
+
- `boundary`
|
|
41
|
+
- `auth_flow`
|
|
42
|
+
|
|
43
|
+
These fixtures are rule evidence examples and are used by rule-specific tests as each rule is implemented.
|
|
44
|
+
|
|
45
|
+
## Implemented rules
|
|
46
|
+
|
|
47
|
+
### Secret-literal rule
|
|
48
|
+
|
|
49
|
+
The secret-literal rule detects hardcoded credential patterns in assignment contexts and dangerous call contexts. It uses AST context to avoid flagging safe retrieval calls such as `process.env.STRIPE_SECRET` or `os.getenv("STRIPE_SECRET")`.
|
|
50
|
+
|
|
51
|
+
### Forbidden-import rule
|
|
52
|
+
|
|
53
|
+
The forbidden-import rule blocks dangerous imports such as `child_process` and disallowed relative traversal imports. It extracts ES module imports and CommonJS `require(...)` calls using AST traversal rather than raw text scanning.
|
|
54
|
+
|
|
55
|
+
### Boundary rule
|
|
56
|
+
|
|
57
|
+
The boundary rule checks AST-extracted import edges against a small declared boundary map. In the current policy, `auth` may import the public DB contract but must not import `db/internal` paths directly.
|
|
58
|
+
|
|
59
|
+
Circular dependency detection is explicitly deferred because Task 08 evaluates single-file import edges only, not a repository-wide import graph.
|
|
60
|
+
|
|
61
|
+
### Auth-flow rule
|
|
62
|
+
|
|
63
|
+
The auth-flow rule checks calls inside an exported `handler` function and requires an explicit auth guard before protected database operations such as `db.user.update(...)`.
|
|
64
|
+
|
|
65
|
+
Current Task 09 behaviour is intentionally narrow:
|
|
66
|
+
|
|
67
|
+
- mutation before auth returns `FAIL`
|
|
68
|
+
- auth before mutation returns `PASS`
|
|
69
|
+
- auth found only inside an ambiguous branch before mutation returns `INCONCLUSIVE`
|
|
70
|
+
|
|
71
|
+
Repository-wide control-flow analysis is not implemented yet.
|
|
72
|
+
|
|
73
|
+
## Decision engine
|
|
74
|
+
|
|
75
|
+
The decision engine runs a supplied ordered list of rules against one source buffer and reduces their rule results into one `KernelResult`.
|
|
76
|
+
|
|
77
|
+
In enforce mode, any rule failure produces `BLOCK`.
|
|
78
|
+
|
|
79
|
+
In shadow mode, rule failures are preserved in the result but the final decision remains `ALLOW`.
|
|
80
|
+
|
|
81
|
+
For Task 10, `INCONCLUSIVE` rule results are recorded but do not block.
|
|
82
|
+
|
|
83
|
+
## Benchmarks
|
|
84
|
+
|
|
85
|
+
Run the current Python kernel benchmark suite with:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python benchmarks/run_benchmarks.py --iterations 50 --warmup 5 --json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Benchmark baseline documentation lives in `docs/benchmarks/baseline.md`.
|
|
92
|
+
|
|
93
|
+
## Local violation reporting
|
|
94
|
+
|
|
95
|
+
Blocked evaluations are written locally as JSONL.
|
|
96
|
+
|
|
97
|
+
Default path:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
.watchllm/logs/violations.jsonl
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Override path:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
WATCHLLM_LOG_PATH=/tmp/watchllm-violations.jsonl python -m watchllm_kernel evaluate path/to/file.ts --json
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The reporting contract is documented in `docs/specs/reporting-contract.md`.
|
|
110
|
+
|
|
111
|
+
## Non‑goals (current state)
|
|
112
|
+
|
|
113
|
+
- No save-path editor integration yet
|
|
114
|
+
- No cloud dependency or network enforcement
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "watchllm-kernel"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Deterministic local runtime governance kernel for autonomous coding agents."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "WatchLLM" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"watchllm",
|
|
17
|
+
"kernel",
|
|
18
|
+
"runtime-governance",
|
|
19
|
+
"agent-governance",
|
|
20
|
+
"static-analysis"
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 1 - Planning",
|
|
24
|
+
"Environment :: Console",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"License :: OSI Approved :: Apache Software License",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
32
|
+
"Topic :: Security"
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"tree-sitter>=0.21.0",
|
|
36
|
+
"tree-sitter-javascript>=0.21.0",
|
|
37
|
+
"tree-sitter-typescript>=0.21.0",
|
|
38
|
+
"pyyaml>=6.0.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
watchllm-kernel = "watchllm_kernel.cli:main"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools]
|
|
45
|
+
package-dir = {"" = "src"}
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
where = ["src"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from watchllm_kernel.models import (
|
|
4
|
+
Decision,
|
|
5
|
+
KernelResult,
|
|
6
|
+
Rule,
|
|
7
|
+
RuleDecision,
|
|
8
|
+
RuleResult,
|
|
9
|
+
Severity,
|
|
10
|
+
SourceLocation,
|
|
11
|
+
Violation,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Decision",
|
|
16
|
+
"KernelResult",
|
|
17
|
+
"Rule",
|
|
18
|
+
"RuleDecision",
|
|
19
|
+
"RuleResult",
|
|
20
|
+
"Severity",
|
|
21
|
+
"SourceLocation",
|
|
22
|
+
"Violation",
|
|
23
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import dataclasses
|
|
3
|
+
import enum
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from watchllm_kernel.engine import ENFORCE_MODE, SHADOW_MODE, evaluate_source
|
|
10
|
+
from watchllm_kernel.models import Decision
|
|
11
|
+
from watchllm_kernel.reporting import format_human_report, write_block_log
|
|
12
|
+
from watchllm_kernel.rules.auth_flow import AuthFlowRule
|
|
13
|
+
from watchllm_kernel.rules.boundary import BoundaryRule
|
|
14
|
+
from watchllm_kernel.rules.forbidden_imports import ForbiddenImportRule
|
|
15
|
+
from watchllm_kernel.config_loader import load_config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Helpers
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _to_jsonable(obj: Any) -> Any:
|
|
24
|
+
"""Convert dataclasses and enums to JSON‑serialisable primitives."""
|
|
25
|
+
if dataclasses.is_dataclass(obj):
|
|
26
|
+
return {f.name: _to_jsonable(getattr(obj, f.name)) for f in dataclasses.fields(obj)}
|
|
27
|
+
if isinstance(obj, enum.Enum):
|
|
28
|
+
return obj.value
|
|
29
|
+
if isinstance(obj, list):
|
|
30
|
+
return [_to_jsonable(item) for item in obj]
|
|
31
|
+
return obj
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_default_rules(config: dict | None = None):
|
|
35
|
+
"""Return the default rule set for the kernel CLI, applying overrides from config.
|
|
36
|
+
"""
|
|
37
|
+
config = config or {}
|
|
38
|
+
rules = []
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
from watchllm_kernel.rules.secrets import SecretLiteralRule
|
|
42
|
+
except ModuleNotFoundError:
|
|
43
|
+
SecretLiteralRule = None
|
|
44
|
+
|
|
45
|
+
if SecretLiteralRule is not None:
|
|
46
|
+
secrets_cfg = config.get("rules", {}).get("secrets", {})
|
|
47
|
+
if secrets_cfg.get("enabled", True):
|
|
48
|
+
rules.append(SecretLiteralRule())
|
|
49
|
+
|
|
50
|
+
# Build Forbidden Import Rule
|
|
51
|
+
fi_cfg = config.get("rules", {}).get("forbidden_imports", {})
|
|
52
|
+
if fi_cfg.get("enabled", True):
|
|
53
|
+
rules.append(ForbiddenImportRule(
|
|
54
|
+
forbidden_modules=fi_cfg.get("modules"),
|
|
55
|
+
forbidden_prefixes=fi_cfg.get("forbidden_prefixes"),
|
|
56
|
+
allowed_relative_prefixes=fi_cfg.get("allowed_relative_prefixes")
|
|
57
|
+
))
|
|
58
|
+
|
|
59
|
+
# Build Boundary Rule
|
|
60
|
+
boundary_cfg = config.get("rules", {}).get("boundary", {})
|
|
61
|
+
if boundary_cfg.get("enabled", True):
|
|
62
|
+
rules.append(BoundaryRule(
|
|
63
|
+
boundary_map=boundary_cfg.get("map")
|
|
64
|
+
))
|
|
65
|
+
|
|
66
|
+
# Build Auth Flow Rule
|
|
67
|
+
auth_cfg = config.get("rules", {}).get("auth_flow", {})
|
|
68
|
+
if auth_cfg.get("enabled", True):
|
|
69
|
+
rules.append(AuthFlowRule())
|
|
70
|
+
|
|
71
|
+
return rules
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Argument parser
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
80
|
+
parser = argparse.ArgumentParser(
|
|
81
|
+
prog="watchllm-kernel",
|
|
82
|
+
description="Deterministic local write-path governance kernel for autonomous coding agents.",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--version",
|
|
86
|
+
action="version",
|
|
87
|
+
version="%(prog)s 0.1.0",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
sub = parser.add_subparsers(dest="command", help="sub-command")
|
|
91
|
+
|
|
92
|
+
# check
|
|
93
|
+
check_parser = sub.add_parser("check", help="Check source against rules")
|
|
94
|
+
check_parser.add_argument(
|
|
95
|
+
"--stdin",
|
|
96
|
+
action="store_true",
|
|
97
|
+
help="Read source from stdin instead of a file path",
|
|
98
|
+
)
|
|
99
|
+
check_parser.add_argument(
|
|
100
|
+
"--filepath",
|
|
101
|
+
default=None,
|
|
102
|
+
help="Path to source file (ignored when --stdin is used)",
|
|
103
|
+
)
|
|
104
|
+
check_parser.add_argument(
|
|
105
|
+
"--language",
|
|
106
|
+
choices=["js", "ts"],
|
|
107
|
+
default=None,
|
|
108
|
+
help="Language identifier (js or ts). Inferred from file extension when omitted.",
|
|
109
|
+
)
|
|
110
|
+
check_parser.add_argument(
|
|
111
|
+
"--mode",
|
|
112
|
+
choices=[ENFORCE_MODE, SHADOW_MODE],
|
|
113
|
+
default=ENFORCE_MODE,
|
|
114
|
+
help="Evaluation mode (default: enforce)",
|
|
115
|
+
)
|
|
116
|
+
check_parser.add_argument(
|
|
117
|
+
"--json",
|
|
118
|
+
action="store_true",
|
|
119
|
+
help="Output result as JSON",
|
|
120
|
+
)
|
|
121
|
+
return parser
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Language resolution
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
_LANGUAGE_SHORT_MAP = {
|
|
129
|
+
"js": "javascript",
|
|
130
|
+
"ts": "typescript",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _resolve_language(language: str | None, file_path: str | None) -> str | None:
|
|
135
|
+
if language and language in _LANGUAGE_SHORT_MAP:
|
|
136
|
+
return _LANGUAGE_SHORT_MAP[language]
|
|
137
|
+
if language:
|
|
138
|
+
return language
|
|
139
|
+
if file_path is None:
|
|
140
|
+
return None
|
|
141
|
+
suffix = Path(file_path).suffix.lower()
|
|
142
|
+
if suffix in (".ts", ".tsx"):
|
|
143
|
+
return "typescript"
|
|
144
|
+
if suffix in (".js", ".jsx", ".mjs", ".cjs"):
|
|
145
|
+
return "javascript"
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Main
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main(argv: list[str] | None = None) -> int:
|
|
155
|
+
parser = build_parser()
|
|
156
|
+
if argv is None:
|
|
157
|
+
argv = sys.argv[1:]
|
|
158
|
+
|
|
159
|
+
if not argv:
|
|
160
|
+
parser.print_help()
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
args = parser.parse_args(argv)
|
|
164
|
+
|
|
165
|
+
if args.command != "check":
|
|
166
|
+
parser.print_help()
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
# --- read source ---
|
|
170
|
+
if args.stdin:
|
|
171
|
+
source = sys.stdin.read()
|
|
172
|
+
file_path = None
|
|
173
|
+
else:
|
|
174
|
+
if args.filepath is None:
|
|
175
|
+
print("Error: either --stdin or --filepath is required", file=sys.stderr)
|
|
176
|
+
return 2
|
|
177
|
+
file_path = args.filepath
|
|
178
|
+
try:
|
|
179
|
+
source = Path(file_path).read_text(encoding="utf-8")
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
print(f"Error reading file {file_path}: {exc}", file=sys.stderr)
|
|
182
|
+
return 2
|
|
183
|
+
|
|
184
|
+
language = _resolve_language(args.language, file_path)
|
|
185
|
+
|
|
186
|
+
# --- load config ---
|
|
187
|
+
start_path = str(Path(file_path).parent) if file_path else "."
|
|
188
|
+
config = load_config(start_path=start_path)
|
|
189
|
+
|
|
190
|
+
# --- evaluate ---
|
|
191
|
+
rules = build_default_rules(config=config)
|
|
192
|
+
result = evaluate_source(
|
|
193
|
+
source,
|
|
194
|
+
file_path=file_path,
|
|
195
|
+
language=language,
|
|
196
|
+
rules=rules,
|
|
197
|
+
mode=args.mode,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# --- local blocked-event logging ---
|
|
201
|
+
write_block_log(result)
|
|
202
|
+
|
|
203
|
+
# --- output ---
|
|
204
|
+
if args.json:
|
|
205
|
+
payload = _to_jsonable(result)
|
|
206
|
+
json.dump(payload, sys.stdout, indent=2)
|
|
207
|
+
sys.stdout.write("\n")
|
|
208
|
+
else:
|
|
209
|
+
print(format_human_report(result))
|
|
210
|
+
|
|
211
|
+
# exit code
|
|
212
|
+
if result.decision == Decision.BLOCK:
|
|
213
|
+
return 1
|
|
214
|
+
return 0
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import yaml
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
CONFIG_FILENAME = ".watchllm.yaml"
|
|
6
|
+
|
|
7
|
+
def find_config(start_path: str = ".") -> Optional[str]:
|
|
8
|
+
"""Search for .watchllm.yaml starting from start_path and moving upwards."""
|
|
9
|
+
current_path = os.path.abspath(start_path)
|
|
10
|
+
|
|
11
|
+
while True:
|
|
12
|
+
potential_config = os.path.join(current_path, CONFIG_FILENAME)
|
|
13
|
+
if os.path.isfile(potential_config):
|
|
14
|
+
return potential_config
|
|
15
|
+
|
|
16
|
+
parent = os.path.dirname(current_path)
|
|
17
|
+
if parent == current_path:
|
|
18
|
+
break
|
|
19
|
+
current_path = parent
|
|
20
|
+
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
def load_config(config_path: Optional[str] = None, start_path: str = ".") -> Dict[str, Any]:
|
|
24
|
+
"""Load the WatchLLM configuration from the given path or auto-discover it."""
|
|
25
|
+
if not config_path:
|
|
26
|
+
config_path = find_config(start_path)
|
|
27
|
+
|
|
28
|
+
if not config_path or not os.path.isfile(config_path):
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
33
|
+
config = yaml.safe_load(f)
|
|
34
|
+
return config if config else {}
|
|
35
|
+
except Exception as e:
|
|
36
|
+
print(f"Warning: Failed to load config from {config_path}: {e}")
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
def get_rule_config(config: Dict[str, Any], rule_name: str) -> Dict[str, Any]:
|
|
40
|
+
"""Extract configuration for a specific rule."""
|
|
41
|
+
if not config or "rules" not in config or not config["rules"]:
|
|
42
|
+
return {}
|
|
43
|
+
return config["rules"].get(rule_name, {})
|