Skip to content

Commit 6245a03

Browse files
committed
Add module allow deny policy hooks
1 parent 361192f commit 6245a03

5 files changed

Lines changed: 156 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ Goal: make multi-file script projects easier to compose and maintain.
259259

260260
- [x] Tighten module root boundary checks and path normalization.
261261
- [x] Add test coverage for path traversal attempts.
262-
- [ ] Add explicit policy hooks for module allow/deny lists.
262+
- [x] Add explicit policy hooks for module allow/deny lists.
263263

264264
### Developer UX
265265

docs/integration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ The interpreter searches each configured directory for `<module>.vibe` in order
7474
and caches compiled modules so subsequent calls to `require` are inexpensive.
7575
For long-running hosts, call `engine.ClearModuleCache()` between runs when
7676
module sources can change.
77+
Use `Config.ModuleAllowList` / `Config.ModuleDenyList` for policy hooks over
78+
which modules may be loaded (`*` glob patterns against normalized module names,
79+
with deny-list rules taking precedence).
7780
When a circular module dependency is detected, the runtime reports a concise
7881
chain (for example `a -> b -> a`).
7982
Use the optional `as:` keyword to bind the loaded module object to a global

vibes/interpreter.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ type Config struct {
1717
StrictEffects bool
1818
RecursionLimit int
1919
ModulePaths []string
20+
ModuleAllowList []string
21+
ModuleDenyList []string
2022
MaxCachedModules int
2123
}
2224

@@ -47,6 +49,12 @@ func NewEngine(cfg Config) (*Engine, error) {
4749
if err := validateModulePaths(cfg.ModulePaths); err != nil {
4850
return nil, err
4951
}
52+
if err := validateModulePolicyPatterns(cfg.ModuleAllowList, "allow"); err != nil {
53+
return nil, err
54+
}
55+
if err := validateModulePolicyPatterns(cfg.ModuleDenyList, "deny"); err != nil {
56+
return nil, err
57+
}
5058

5159
engine := &Engine{
5260
config: cfg,

vibes/modules.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"io/fs"
77
"os"
8+
"path"
89
"path/filepath"
910
"reflect"
1011
"slices"
@@ -26,6 +27,80 @@ type moduleRequest struct {
2627

2728
const moduleKeySeparator = "::"
2829

30+
func normalizeModulePolicyPattern(pattern string) string {
31+
normalized := strings.TrimSpace(pattern)
32+
normalized = strings.ReplaceAll(normalized, "\\", "/")
33+
normalized = strings.TrimPrefix(normalized, "./")
34+
normalized = strings.TrimSuffix(normalized, ".vibe")
35+
normalized = path.Clean(normalized)
36+
if normalized == "." {
37+
return ""
38+
}
39+
return normalized
40+
}
41+
42+
func normalizeModulePolicyModuleName(relative string) string {
43+
normalized := filepath.ToSlash(filepath.Clean(relative))
44+
normalized = strings.TrimPrefix(normalized, "./")
45+
normalized = strings.TrimSuffix(normalized, ".vibe")
46+
if normalized == "." {
47+
return ""
48+
}
49+
return normalized
50+
}
51+
52+
func validateModulePolicyPatterns(patterns []string, label string) error {
53+
for _, raw := range patterns {
54+
pattern := normalizeModulePolicyPattern(raw)
55+
if pattern == "" {
56+
return fmt.Errorf("vibes: module %s-list pattern cannot be empty", label)
57+
}
58+
if _, err := path.Match(pattern, "probe"); err != nil {
59+
return fmt.Errorf("vibes: invalid module %s-list pattern %q: %w", label, raw, err)
60+
}
61+
}
62+
return nil
63+
}
64+
65+
func modulePolicyMatch(pattern string, module string) bool {
66+
matched, err := path.Match(pattern, module)
67+
if err != nil {
68+
return false
69+
}
70+
return matched
71+
}
72+
73+
func (e *Engine) enforceModulePolicy(relative string) error {
74+
module := normalizeModulePolicyModuleName(relative)
75+
if module == "" {
76+
return nil
77+
}
78+
79+
for _, raw := range e.config.ModuleDenyList {
80+
pattern := normalizeModulePolicyPattern(raw)
81+
if pattern == "" {
82+
continue
83+
}
84+
if modulePolicyMatch(pattern, module) {
85+
return fmt.Errorf("require: module %q denied by policy", module)
86+
}
87+
}
88+
89+
if len(e.config.ModuleAllowList) == 0 {
90+
return nil
91+
}
92+
for _, raw := range e.config.ModuleAllowList {
93+
pattern := normalizeModulePolicyPattern(raw)
94+
if pattern == "" {
95+
continue
96+
}
97+
if modulePolicyMatch(pattern, module) {
98+
return nil
99+
}
100+
}
101+
return fmt.Errorf("require: module %q not allowed by policy", module)
102+
}
103+
29104
func parseModuleRequest(name string) (moduleRequest, error) {
30105
trimmed := strings.TrimSpace(name)
31106
if trimmed == "" {
@@ -271,6 +346,10 @@ func (e *Engine) loadSearchPathModule(request moduleRequest) (moduleEntry, error
271346
}
272347

273348
func (e *Engine) compileAndCacheModule(key, root, relative, fullPath string, content []byte) (moduleEntry, error) {
349+
if err := e.enforceModulePolicy(relative); err != nil {
350+
return moduleEntry{}, err
351+
}
352+
274353
script, err := e.Compile(string(content))
275354
if err != nil {
276355
return moduleEntry{}, fmt.Errorf("require: compiling %s failed: %w", fullPath, err)

vibes/modules_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,71 @@ end`)
947947
}
948948
}
949949

950+
func TestRequireModuleAllowList(t *testing.T) {
951+
engine := MustNewEngine(Config{
952+
ModulePaths: []string{filepath.Join("testdata", "modules")},
953+
ModuleAllowList: []string{"shared/*"},
954+
})
955+
956+
script, err := engine.Compile(`def run_allowed(value)
957+
mod = require("shared/math")
958+
mod.double(value)
959+
end
960+
961+
def run_denied(value)
962+
mod = require("helper")
963+
mod.double(value)
964+
end`)
965+
if err != nil {
966+
t.Fatalf("compile failed: %v", err)
967+
}
968+
969+
allowed, err := script.Call(context.Background(), "run_allowed", []Value{NewInt(3)}, CallOptions{})
970+
if err != nil {
971+
t.Fatalf("allowed call failed: %v", err)
972+
}
973+
if allowed.Kind() != KindInt || allowed.Int() != 6 {
974+
t.Fatalf("expected allowed result 6, got %#v", allowed)
975+
}
976+
977+
if _, err := script.Call(context.Background(), "run_denied", []Value{NewInt(3)}, CallOptions{}); err == nil {
978+
t.Fatalf("expected denied module error")
979+
} else if !strings.Contains(err.Error(), `require: module "helper" not allowed by policy`) {
980+
t.Fatalf("unexpected error: %v", err)
981+
}
982+
}
983+
984+
func TestRequireModuleDenyListOverridesAllowList(t *testing.T) {
985+
engine := MustNewEngine(Config{
986+
ModulePaths: []string{filepath.Join("testdata", "modules")},
987+
ModuleAllowList: []string{"*"},
988+
ModuleDenyList: []string{"helper"},
989+
})
990+
991+
script, err := engine.Compile(`def run()
992+
require("helper")
993+
end`)
994+
if err != nil {
995+
t.Fatalf("compile failed: %v", err)
996+
}
997+
998+
if _, err := script.Call(context.Background(), "run", nil, CallOptions{}); err == nil {
999+
t.Fatalf("expected deny-list error")
1000+
} else if !strings.Contains(err.Error(), `require: module "helper" denied by policy`) {
1001+
t.Fatalf("unexpected error: %v", err)
1002+
}
1003+
}
1004+
1005+
func TestModulePolicyPatternValidation(t *testing.T) {
1006+
_, err := NewEngine(Config{
1007+
ModulePaths: []string{filepath.Join("testdata", "modules")},
1008+
ModuleAllowList: []string{"[invalid"},
1009+
})
1010+
if err == nil || !strings.Contains(err.Error(), "invalid module allow-list pattern") {
1011+
t.Fatalf("expected invalid allow-list pattern error, got %v", err)
1012+
}
1013+
}
1014+
9501015
func TestFormatModuleCycleUsesConciseChain(t *testing.T) {
9511016
root := filepath.Join("tmp", "modules")
9521017
a := moduleCacheKey(root, filepath.Join("nested", "a.vibe"))

0 commit comments

Comments
 (0)