Add AST-based security middleware and enforcement wiring
This commit is contained in:
10
.env.example
10
.env.example
@@ -31,3 +31,13 @@ AGENT_PORT_BLOCK_COUNT=512
|
|||||||
AGENT_PORT_PRIMARY_OFFSET=0
|
AGENT_PORT_PRIMARY_OFFSET=0
|
||||||
AGENT_PORT_LOCK_DIR=.ai_ops/locks/ports
|
AGENT_PORT_LOCK_DIR=.ai_ops/locks/ports
|
||||||
AGENT_DISCOVERY_FILE_RELATIVE_PATH=.agent-context/resources.json
|
AGENT_DISCOVERY_FILE_RELATIVE_PATH=.agent-context/resources.json
|
||||||
|
|
||||||
|
# Security middleware
|
||||||
|
AGENT_SECURITY_VIOLATION_MODE=hard_abort
|
||||||
|
AGENT_SECURITY_ALLOWED_BINARIES=git,npm,node,cat,ls,pwd,echo,bash,sh
|
||||||
|
AGENT_SECURITY_COMMAND_TIMEOUT_MS=120000
|
||||||
|
AGENT_SECURITY_AUDIT_LOG_PATH=.ai_ops/security/command-audit.ndjson
|
||||||
|
AGENT_SECURITY_ENV_INHERIT=PATH,HOME,TMPDIR,TMP,TEMP,LANG,LC_ALL
|
||||||
|
AGENT_SECURITY_ENV_SCRUB=
|
||||||
|
AGENT_SECURITY_DROP_UID=
|
||||||
|
AGENT_SECURITY_DROP_GID=
|
||||||
|
|||||||
@@ -38,6 +38,15 @@
|
|||||||
- `AGENT_PORT_PRIMARY_OFFSET`
|
- `AGENT_PORT_PRIMARY_OFFSET`
|
||||||
- `AGENT_PORT_LOCK_DIR`
|
- `AGENT_PORT_LOCK_DIR`
|
||||||
- `AGENT_DISCOVERY_FILE_RELATIVE_PATH`
|
- `AGENT_DISCOVERY_FILE_RELATIVE_PATH`
|
||||||
|
- Security middleware controls:
|
||||||
|
- `AGENT_SECURITY_VIOLATION_MODE`
|
||||||
|
- `AGENT_SECURITY_ALLOWED_BINARIES`
|
||||||
|
- `AGENT_SECURITY_COMMAND_TIMEOUT_MS`
|
||||||
|
- `AGENT_SECURITY_AUDIT_LOG_PATH`
|
||||||
|
- `AGENT_SECURITY_ENV_INHERIT`
|
||||||
|
- `AGENT_SECURITY_ENV_SCRUB`
|
||||||
|
- `AGENT_SECURITY_DROP_UID`
|
||||||
|
- `AGENT_SECURITY_DROP_GID`
|
||||||
|
|
||||||
## Documentation Standards
|
## Documentation Standards
|
||||||
- Update `README.md` for user-facing behavior.
|
- Update `README.md` for user-facing behavior.
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -10,6 +10,7 @@ TypeScript runtime for deterministic multi-agent execution with:
|
|||||||
- Typed domain events for edge-triggered routing
|
- Typed domain events for edge-triggered routing
|
||||||
- Resource provisioning (git worktrees + deterministic port ranges)
|
- Resource provisioning (git worktrees + deterministic port ranges)
|
||||||
- MCP configuration layer with handler policy hooks
|
- MCP configuration layer with handler policy hooks
|
||||||
|
- Security middleware for shell/tool policy enforcement
|
||||||
|
|
||||||
## Architecture Summary
|
## Architecture Summary
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ TypeScript runtime for deterministic multi-agent execution with:
|
|||||||
- `runtime.ts`: env-driven defaults/singletons
|
- `runtime.ts`: env-driven defaults/singletons
|
||||||
- `provisioning.ts`: resource provisioning and child suballocation helpers
|
- `provisioning.ts`: resource provisioning and child suballocation helpers
|
||||||
- `src/mcp`: MCP config types/conversion/handlers
|
- `src/mcp`: MCP config types/conversion/handlers
|
||||||
|
- `src/security`: shell AST parsing, rules engine, secure executor, and audit sinks
|
||||||
- `src/examples`: provider entrypoints (`codex.ts`, `claude.ts`)
|
- `src/examples`: provider entrypoints (`codex.ts`, `claude.ts`)
|
||||||
- `src/config.ts`: centralized env parsing/validation/defaulting
|
- `src/config.ts`: centralized env parsing/validation/defaulting
|
||||||
- `tests`: manager, manifest, pipeline/orchestration, state, provisioning, MCP
|
- `tests`: manager, manifest, pipeline/orchestration, state, provisioning, MCP
|
||||||
@@ -69,6 +71,7 @@ npm run dev -- claude "List potential improvements."
|
|||||||
|
|
||||||
- supported topologies (`sequential`, `parallel`, `hierarchical`, `retry-unrolled`)
|
- supported topologies (`sequential`, `parallel`, `hierarchical`, `retry-unrolled`)
|
||||||
- persona definitions and tool-clearance metadata
|
- persona definitions and tool-clearance metadata
|
||||||
|
- persona definitions and tool-clearance policy (validated by shared Zod schema)
|
||||||
- relationship DAG and unknown persona references
|
- relationship DAG and unknown persona references
|
||||||
- strict pipeline DAG
|
- strict pipeline DAG
|
||||||
- topology constraints (`maxDepth`, `maxRetries`)
|
- topology constraints (`maxDepth`, `maxRetries`)
|
||||||
@@ -98,6 +101,26 @@ Actors can emit events in `ActorExecutionResult.events`. Pipeline status also em
|
|||||||
- session closure aborts child recursive work
|
- session closure aborts child recursive work
|
||||||
- run summaries expose aggregate `status`: success requires successful terminal executed DAG nodes and no critical-path failure
|
- run summaries expose aggregate `status`: success requires successful terminal executed DAG nodes and no critical-path failure
|
||||||
|
|
||||||
|
## Security Middleware
|
||||||
|
|
||||||
|
- Shell command parsing uses `bash-parser` AST traversal and extracts `Command`/`Word` nodes.
|
||||||
|
- Rules are validated with strict Zod schemas (`src/security/schemas.ts`) before execution.
|
||||||
|
- `SecurityRulesEngine` enforces:
|
||||||
|
- binary allowlists
|
||||||
|
- cwd/worktree boundary checks
|
||||||
|
- path traversal blocking (`../`)
|
||||||
|
- protected path blocking (state root + project context path)
|
||||||
|
- unified tool allowlist/banlist checks for shell binaries and MCP tool lists
|
||||||
|
- `SecureCommandExecutor` runs commands via `child_process.spawn` with:
|
||||||
|
- explicit env scrub/inject policy (no implicit full env inheritance)
|
||||||
|
- timeout enforcement
|
||||||
|
- optional uid/gid drop
|
||||||
|
- stdout/stderr streaming hooks for audit
|
||||||
|
- Every actor execution input now includes `security` helpers (`rulesEngine`, `createCommandExecutor(...)`) so executors can enforce shell/tool policy at the execution boundary.
|
||||||
|
- Pipeline behavior on `SecurityViolationError` is configurable:
|
||||||
|
- `hard_abort` (default)
|
||||||
|
- `validation_fail` (retry-unrolled remediation)
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
### Provider/Auth
|
### Provider/Auth
|
||||||
@@ -136,6 +159,17 @@ Actors can emit events in `ActorExecutionResult.events`. Pipeline status also em
|
|||||||
- `AGENT_PORT_LOCK_DIR`
|
- `AGENT_PORT_LOCK_DIR`
|
||||||
- `AGENT_DISCOVERY_FILE_RELATIVE_PATH`
|
- `AGENT_DISCOVERY_FILE_RELATIVE_PATH`
|
||||||
|
|
||||||
|
### Security Middleware
|
||||||
|
|
||||||
|
- `AGENT_SECURITY_VIOLATION_MODE`
|
||||||
|
- `AGENT_SECURITY_ALLOWED_BINARIES`
|
||||||
|
- `AGENT_SECURITY_COMMAND_TIMEOUT_MS`
|
||||||
|
- `AGENT_SECURITY_AUDIT_LOG_PATH`
|
||||||
|
- `AGENT_SECURITY_ENV_INHERIT`
|
||||||
|
- `AGENT_SECURITY_ENV_SCRUB`
|
||||||
|
- `AGENT_SECURITY_DROP_UID`
|
||||||
|
- `AGENT_SECURITY_DROP_GID`
|
||||||
|
|
||||||
Defaults are documented in `.env.example`.
|
Defaults are documented in `.env.example`.
|
||||||
|
|
||||||
## Quality Gate
|
## Quality Gate
|
||||||
@@ -155,5 +189,4 @@ npm run build
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Tool clearance allowlist/banlist is currently metadata only; hard enforcement must happen at the tool execution boundary.
|
|
||||||
- `AgentManager.runRecursiveAgent(...)` remains available for low-level testing, but pipeline execution should use `SchemaDrivenExecutionEngine.runSession(...)`.
|
- `AgentManager.runRecursiveAgent(...)` remains available for low-level testing, but pipeline execution should use `SchemaDrivenExecutionEngine.runSession(...)`.
|
||||||
|
|||||||
@@ -45,4 +45,13 @@ Node payloads are persisted under the state root. Nodes do not inherit in-memory
|
|||||||
|
|
||||||
## Security note
|
## Security note
|
||||||
|
|
||||||
Tool clearance allowlists/banlists are currently data-model stubs. Enforcement must be implemented in the tool execution boundary before relying on these policies for hard guarantees.
|
Security enforcement now lives in `src/security`:
|
||||||
|
|
||||||
|
- `bash-parser` AST parsing for shell command tokenization (`Command`/`Word` nodes).
|
||||||
|
- Zod-validated shell/tool policy schemas.
|
||||||
|
- `SecurityRulesEngine` for binary allowlists, path traversal checks, worktree boundaries, and tool clearance checks.
|
||||||
|
- `SecureCommandExecutor` for controlled `child_process` execution with timeout + explicit env policy.
|
||||||
|
|
||||||
|
`PipelineExecutor` treats `SecurityViolationError` via configurable policy:
|
||||||
|
- `hard_abort` (default): immediate pipeline termination.
|
||||||
|
- `validation_fail`: maps to retry-unrolled remediation.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
- `PipelineExecutor` (`src/agents/pipeline.ts`)
|
- `PipelineExecutor` (`src/agents/pipeline.ts`)
|
||||||
- Coordinates DAG traversal and retry behavior.
|
- Coordinates DAG traversal and retry behavior.
|
||||||
- Computes aggregate run status from executed terminal nodes plus critical-path failures.
|
- Computes aggregate run status from executed terminal nodes plus critical-path failures.
|
||||||
|
- Applies dedicated `SecurityViolationError` handling policy (`hard_abort` or `validation_fail` mapping).
|
||||||
|
|
||||||
## Aggregate status semantics
|
## Aggregate status semantics
|
||||||
|
|
||||||
|
|||||||
42
docs/security-middleware.md
Normal file
42
docs/security-middleware.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Security Middleware
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This middleware provides a first-pass hardening layer for agent-executed shell commands and MCP tool usage.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- `src/security/shell-parser.ts`
|
||||||
|
- Uses `bash-parser` to parse shell scripts and extract command-level data from `Command` and `Word` nodes.
|
||||||
|
- Traverses nested constructs (logical operators, pipelines, subshells, command expansions) so chained commands are fully visible to policy checks.
|
||||||
|
- `src/security/schemas.ts`
|
||||||
|
- Zod schemas for shell policies, tool clearance policies, execution env policy, and security violation handling mode.
|
||||||
|
- `src/security/rules-engine.ts`
|
||||||
|
- Enforces:
|
||||||
|
- binary allowlist
|
||||||
|
- cwd boundary (`AGENT_WORKTREE_ROOT`)
|
||||||
|
- argument path boundary and `../` traversal rejection
|
||||||
|
- protected path blocking (`AGENT_STATE_ROOT`, `AGENT_PROJECT_CONTEXT_PATH`)
|
||||||
|
- unified tool allowlist/banlist checks
|
||||||
|
- `src/security/executor.ts`
|
||||||
|
- Runs commands via `child_process.spawn` using explicit env construction (no implicit full parent env inheritance).
|
||||||
|
- Supports timeout enforcement, optional uid/gid drop, and stdout/stderr stream callbacks.
|
||||||
|
- `src/security/audit-log.ts`
|
||||||
|
- File-backed audit sink (`ndjson`) for profiling emitted shell commands and tool access decisions.
|
||||||
|
|
||||||
|
## Pipeline behavior
|
||||||
|
|
||||||
|
`SecurityViolationError` is treated as a first-class error type by `PipelineExecutor`:
|
||||||
|
|
||||||
|
- `hard_abort` (default): fail fast and stop the pipeline.
|
||||||
|
- `validation_fail`: map violation to retry-unrolled behavior so the actor can attempt a compliant alternative.
|
||||||
|
|
||||||
|
## MCP integration
|
||||||
|
|
||||||
|
`McpRegistry.resolveServerWithHandler(...)` now accepts optional tool clearance and applies it to resolved Codex MCP tool lists (`enabled_tools`, `disabled_tools`).
|
||||||
|
|
||||||
|
## Known limits and TODOs
|
||||||
|
|
||||||
|
- AST validation is token-based and does not yet model full shell evaluation semantics (e.g. runtime-generated paths from env expansion).
|
||||||
|
- Audit output is line-oriented file logging; move to a centralized telemetry pipeline for long-term profiling.
|
||||||
|
- Deno sandbox mode is not enforced yet. A future executor mode can wrap shell runs via `deno run` with strict `--allow-read/--allow-run` flags.
|
||||||
256
package-lock.json
generated
256
package-lock.json
generated
@@ -11,7 +11,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.50",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.50",
|
||||||
"@openai/codex-sdk": "^0.104.0",
|
"@openai/codex-sdk": "^0.104.0",
|
||||||
"dotenv": "^17.3.1"
|
"bash-parser": "^0.5.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.0",
|
||||||
@@ -932,6 +934,85 @@
|
|||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/arity-n": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/arity-n/-/arity-n-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-fExL2kFDC1Q2DUOx3whE/9KoN66IzkY4b4zUHUBFM1ojEYjZZYDcUW3bek/ufGionX9giIKDC5redH2IlGqcQQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/array-last": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/array-last/-/array-last-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-eOCut5rXlI6aCOS7Z7kCplKRKyiFQ6dHFBem4PwlwKeNFk2/XxTrhRh5T9PyaEWGy/NHTZWbY+nsZlNFJu9rYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-number": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/babylon": {
|
||||||
|
"version": "6.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz",
|
||||||
|
"integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"babylon": "bin/babylon.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bash-parser": {
|
||||||
|
"version": "0.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bash-parser/-/bash-parser-0.5.0.tgz",
|
||||||
|
"integrity": "sha512-AQR43o4W4sj4Jf+oy4cFtGgyBps4B+MYnJg6Xds8VVC7yomFtQekhOORQNHfQ8D6YJ0XENykr3TpxMn3rUtgeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"array-last": "^1.1.1",
|
||||||
|
"babylon": "^6.9.1",
|
||||||
|
"compose-function": "^3.0.3",
|
||||||
|
"curry": "^1.2.0",
|
||||||
|
"deep-freeze": "0.0.1",
|
||||||
|
"filter-iterator": "0.0.1",
|
||||||
|
"filter-obj": "^1.1.0",
|
||||||
|
"has-own-property": "^0.1.0",
|
||||||
|
"identity-function": "^1.0.0",
|
||||||
|
"iterable-lookahead": "^1.0.0",
|
||||||
|
"iterable-transform-replace": "^1.1.1",
|
||||||
|
"magic-string": "^0.16.0",
|
||||||
|
"map-iterable": "^1.0.1",
|
||||||
|
"map-obj": "^2.0.0",
|
||||||
|
"object-pairs": "^0.1.0",
|
||||||
|
"object-values": "^1.0.0",
|
||||||
|
"reverse-arguments": "^1.0.0",
|
||||||
|
"shell-quote-word": "^1.0.1",
|
||||||
|
"to-pascal-case": "^1.0.0",
|
||||||
|
"transform-spread-iterable": "^1.1.0",
|
||||||
|
"unescape-js": "^1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compose-function": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/compose-function/-/compose-function-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-xzhzTJ5eC+gmIzvZq+C3kCJHsp9os6tJkrigDRZclyGtOKINbZtE8n1Tzmeh32jW+BUDPbvZpibwvJHBLGMVwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"arity-n": "^1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/curry": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/curry/-/curry-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-PAdmqPH2DUYTCc/aknv6RxRxmqdRHclvbz+wP8t1Xpg2Nu13qg+oLb6/5iFoDmf4dbmC9loYoy9PwwGbFt/AqA=="
|
||||||
|
},
|
||||||
|
"node_modules/deep-freeze": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-freeze/-/deep-freeze-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-Z+z8HiAvsGwmjqlphnHW5oz6yWlOwu6EQfFTjmeTWlDeda3FS2yv3jhq35TX/ewmsnqB+RX2IdsIOyjJCQN5tg==",
|
||||||
|
"license": "public domain"
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.3.1",
|
"version": "17.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||||
@@ -986,6 +1067,20 @@
|
|||||||
"@esbuild/win32-x64": "0.27.3"
|
"@esbuild/win32-x64": "0.27.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/filter-iterator": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/filter-iterator/-/filter-iterator-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-v4lhL7Qa8XpbW3LN46CEnmhGk3eHZwxfNl5at20aEkreesht4YKb/Ba3BUIbnPhAC/r3dmu7ABaGk6MAvh2alA=="
|
||||||
|
},
|
||||||
|
"node_modules/filter-obj": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1014,6 +1109,100 @@
|
|||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-own-property": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-own-property/-/has-own-property-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-14qdBKoonU99XDhWcFKZTShK+QV47qU97u8zzoVo9cL5TZ3BmBHXogItSt9qJjR0KUMFRhcCW8uGIGl8nkl7Aw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/identity-function": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/identity-function/-/identity-function-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-kNrgUK0qI+9qLTBidsH85HjDLpZfrrS0ElquKKe/fJFdB3D7VeKdXXEvOPDUHSHOzdZKCAAaQIWWyp0l2yq6pw==",
|
||||||
|
"license": "public domain"
|
||||||
|
},
|
||||||
|
"node_modules/is-iterable": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-iterable/-/is-iterable-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-EdOZCr0NsGE00Pot+x1ZFx9MJK3C6wy91geZpXwvwexDLJvA4nzYyZf7r+EIwSeVsOLDdBz7ATg9NqKTzuNYuQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-number": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iterable-lookahead": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/iterable-lookahead/-/iterable-lookahead-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-hJnEP2Xk4+44DDwJqUQGdXal5VbyeWLaPyDl2AQc242Zr7iqz4DgpQOrEzglWVMGHMDCkguLHEKxd1+rOsmgSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iterable-transform-replace": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/iterable-transform-replace/-/iterable-transform-replace-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-AVCCj7CTUifWQ0ubraDgx5/e6tOWaL5qh/C8BDTjH0GuhNyFMCSsSmDtYpa4Y3ReAAQNSjUWfQ+ojhmjX10pdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"curry": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/magic-string": {
|
||||||
|
"version": "0.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.16.0.tgz",
|
||||||
|
"integrity": "sha512-c4BEos3y6G2qO0B9X7K0FVLOPT9uGrjYwYRLFmDqyl5YMboUviyecnXWp94fJTSMwPw2/sf+CEYt5AGpmklkkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"vlq": "^0.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/map-iterable": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/map-iterable/-/map-iterable-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-siKFftph+ka2jWt8faiOWFzKP+eEuXrHuhYBitssJ5zJm209FCw5JBnaNLDiaCCb/CYZmxprdM6P7p16nA6YRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"curry": "^1.2.0",
|
||||||
|
"is-iterable": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/map-obj": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-TzQSV2DiMYgoF5RycneKVUzIa9bQsj/B3tTgsE3dOGqlzHnGIDaC7XBE7grnA+8kZPnfqSGFe95VHc2oc0VFUQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-pairs": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-pairs/-/object-pairs-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-3ECr6K831I4xX/Mduxr9UC+HPOz/d6WKKYj9p4cmC8Lg8p7g8gitzsxNX5IWlSIgFWN/a4JgrJaoAMKn20oKwA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/object-values": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-values/-/object-values-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-+8hwcz/JnQ9EpLIXzN0Rs7DLsBpJNT/xYehtB/jU93tHYr5BFEO8E+JGQNOSqE7opVzz5cGksKFHt7uUJVLSjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve-pkg-maps": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
@@ -1024,6 +1213,56 @@
|
|||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reverse-arguments": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/reverse-arguments/-/reverse-arguments-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-/x8uIPdTafBqakK0TmPNJzgkLP+3H+yxpUJhCQHsLBg1rYEVNR2D8BRYNWQhVBjyOd7oo1dZRVzIkwMY2oqfYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/shell-quote-word": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/shell-quote-word/-/shell-quote-word-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-lT297f1WLAdq0A4O+AknIFRP6kkiI3s8C913eJ0XqBxJbZPGWUNkRQk2u8zk4bEAjUJ5i+fSLwB6z1HzeT+DEg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/string.fromcodepoint": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string.fromcodepoint/-/string.fromcodepoint-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-n69H31OnxSGSZyZbgBlvYIXlrMhJQ0dQAX1js1QDhpaUH6zmU3QYlj07bCwCNlPOu3oRXIubGPl2gDGnHsiCqg=="
|
||||||
|
},
|
||||||
|
"node_modules/to-no-case": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/to-no-case/-/to-no-case-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Z3g735FxuZY8rodxV4gH7LxClE4H0hTIyHNIHdk+vpQxjLm0cwnKXq/OFVZ76SOQmto7txVcwSCwkU5kqp+FKg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/to-pascal-case": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/to-pascal-case/-/to-pascal-case-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-QGMWHqM6xPrcQW57S23c5/3BbYb0Tbe9p+ur98ckRnGDwD4wbbtDiYI38CfmMKNB5Iv0REjs5SNDntTwvDxzZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"to-space-case": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/to-space-case": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/to-space-case/-/to-space-case-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-rLdvwXZ39VOn1IxGL3V6ZstoTbwLRckQmn/U8ZDLuWwIXNpuZDhQ3AiRUlhTbOXFVE9C+dR51wM0CBDhk31VcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"to-no-case": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/transform-spread-iterable": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/transform-spread-iterable/-/transform-spread-iterable-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-/GnF26X3zC8wfWyRzvuXX/Vb31TrU3Rwipmr4MC5hTi6X/yOXxXUSw4+pcHmKJ2+0KRrcS21YWZw77ukhVJBdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"curry": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
@@ -1065,6 +1304,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/unescape-js": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/unescape-js/-/unescape-js-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-42SD8NOQEhdYntEiUQdYq/1V/YHwr1HLwlHuTJB5InVVdOSbgI6xu8jK5q65yIzuFCfczzyDF/7hbGzVbyCw0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"string.fromcodepoint": "^0.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vlq": {
|
||||||
|
"version": "0.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/vlq/-/vlq-0.2.3.tgz",
|
||||||
|
"integrity": "sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
|
|||||||
@@ -28,7 +28,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.50",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.50",
|
||||||
"@openai/codex-sdk": "^0.104.0",
|
"@openai/codex-sdk": "^0.104.0",
|
||||||
"dotenv": "^17.3.1"
|
"bash-parser": "^0.5.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.0",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { isRecord } from "./types.js";
|
import { isRecord } from "./types.js";
|
||||||
import { isDomainEventType, type DomainEventType } from "./domain-events.js";
|
import { isDomainEventType, type DomainEventType } from "./domain-events.js";
|
||||||
|
import {
|
||||||
|
parseToolClearancePolicy,
|
||||||
|
type ToolClearancePolicy as SecurityToolClearancePolicy,
|
||||||
|
} from "../security/schemas.js";
|
||||||
|
|
||||||
export type ToolClearancePolicy = {
|
export type ToolClearancePolicy = SecurityToolClearancePolicy;
|
||||||
allowlist: string[];
|
|
||||||
banlist: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ManifestPersona = {
|
export type ManifestPersona = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -139,14 +140,12 @@ function readStringArray(record: Record<string, unknown>, key: string): string[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseToolClearance(value: unknown): ToolClearancePolicy {
|
function parseToolClearance(value: unknown): ToolClearancePolicy {
|
||||||
if (!isRecord(value)) {
|
try {
|
||||||
throw new Error("Manifest persona toolClearance must be an object.");
|
return parseToolClearancePolicy(value);
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Manifest persona toolClearance is invalid: ${detail}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
allowlist: readStringArray(value, "allowlist"),
|
|
||||||
banlist: readStringArray(value, "banlist"),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePersona(value: unknown): ManifestPersona {
|
function parsePersona(value: unknown): ManifestPersona {
|
||||||
|
|||||||
@@ -8,10 +8,20 @@ import {
|
|||||||
type PersonaBehaviorEvent,
|
type PersonaBehaviorEvent,
|
||||||
type PersonaBehaviorHandler,
|
type PersonaBehaviorHandler,
|
||||||
} from "./persona-registry.js";
|
} from "./persona-registry.js";
|
||||||
import { PipelineExecutor, type ActorExecutor, type PipelineRunSummary } from "./pipeline.js";
|
import {
|
||||||
|
PipelineExecutor,
|
||||||
|
type ActorExecutionSecurityContext,
|
||||||
|
type ActorExecutor,
|
||||||
|
type PipelineRunSummary,
|
||||||
|
} from "./pipeline.js";
|
||||||
import { FileSystemProjectContextStore } from "./project-context.js";
|
import { FileSystemProjectContextStore } from "./project-context.js";
|
||||||
import { FileSystemStateContextManager, type StoredSessionState } from "./state-context.js";
|
import { FileSystemStateContextManager, type StoredSessionState } from "./state-context.js";
|
||||||
import type { JsonObject } from "./types.js";
|
import type { JsonObject } from "./types.js";
|
||||||
|
import {
|
||||||
|
SecureCommandExecutor,
|
||||||
|
SecurityRulesEngine,
|
||||||
|
createFileSecurityAuditSink,
|
||||||
|
} from "../security/index.js";
|
||||||
|
|
||||||
export type OrchestrationSettings = {
|
export type OrchestrationSettings = {
|
||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
@@ -20,6 +30,7 @@ export type OrchestrationSettings = {
|
|||||||
maxDepth: number;
|
maxDepth: number;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
maxChildren: number;
|
maxChildren: number;
|
||||||
|
securityViolationHandling: "hard_abort" | "validation_fail";
|
||||||
runtimeContext: Record<string, string | number | boolean>;
|
runtimeContext: Record<string, string | number | boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,6 +48,7 @@ export function loadOrchestrationSettingsFromEnv(
|
|||||||
maxDepth: config.orchestration.maxDepth,
|
maxDepth: config.orchestration.maxDepth,
|
||||||
maxRetries: config.orchestration.maxRetries,
|
maxRetries: config.orchestration.maxRetries,
|
||||||
maxChildren: config.orchestration.maxChildren,
|
maxChildren: config.orchestration.maxChildren,
|
||||||
|
securityViolationHandling: config.security.violationHandling,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +76,50 @@ function getChildrenByParent(manifest: AgentManifest): Map<string, AgentManifest
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createActorSecurityContext(input: {
|
||||||
|
config: Readonly<AppConfig>;
|
||||||
|
settings: OrchestrationSettings;
|
||||||
|
}): ActorExecutionSecurityContext {
|
||||||
|
const auditSink = createFileSecurityAuditSink(
|
||||||
|
resolve(input.settings.workspaceRoot, input.config.security.auditLogPath),
|
||||||
|
);
|
||||||
|
const rulesEngine = new SecurityRulesEngine(
|
||||||
|
{
|
||||||
|
allowedBinaries: input.config.security.shellAllowedBinaries,
|
||||||
|
worktreeRoot: resolve(
|
||||||
|
input.settings.workspaceRoot,
|
||||||
|
input.config.provisioning.gitWorktree.rootDirectory,
|
||||||
|
),
|
||||||
|
protectedPaths: [input.settings.stateRoot, input.settings.projectContextPath],
|
||||||
|
requireCwdWithinWorktree: true,
|
||||||
|
rejectRelativePathTraversal: true,
|
||||||
|
enforcePathBoundaryOnArguments: true,
|
||||||
|
allowedEnvAssignments: [],
|
||||||
|
blockedEnvAssignments: ["AGENT_STATE_ROOT", "AGENT_PROJECT_CONTEXT_PATH"],
|
||||||
|
},
|
||||||
|
auditSink,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rulesEngine,
|
||||||
|
createCommandExecutor: (overrides) =>
|
||||||
|
new SecureCommandExecutor({
|
||||||
|
rulesEngine,
|
||||||
|
timeoutMs: overrides?.timeoutMs ?? input.config.security.commandTimeoutMs,
|
||||||
|
envPolicy:
|
||||||
|
overrides?.envPolicy ??
|
||||||
|
{
|
||||||
|
inherit: [...input.config.security.inheritedEnvVars],
|
||||||
|
scrub: [...input.config.security.scrubbedEnvVars],
|
||||||
|
inject: {},
|
||||||
|
},
|
||||||
|
shellPath: overrides?.shellPath,
|
||||||
|
uid: overrides?.uid ?? input.config.security.dropUid,
|
||||||
|
gid: overrides?.gid ?? input.config.security.dropGid,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class SchemaDrivenExecutionEngine {
|
export class SchemaDrivenExecutionEngine {
|
||||||
private readonly manifest: AgentManifest;
|
private readonly manifest: AgentManifest;
|
||||||
private readonly personaRegistry = new PersonaRegistry();
|
private readonly personaRegistry = new PersonaRegistry();
|
||||||
@@ -74,6 +130,7 @@ export class SchemaDrivenExecutionEngine {
|
|||||||
private readonly childrenByParent: Map<string, AgentManifest["relationships"]>;
|
private readonly childrenByParent: Map<string, AgentManifest["relationships"]>;
|
||||||
private readonly manager: AgentManager;
|
private readonly manager: AgentManager;
|
||||||
private readonly mcpRegistry: McpRegistry;
|
private readonly mcpRegistry: McpRegistry;
|
||||||
|
private readonly securityContext: ActorExecutionSecurityContext;
|
||||||
|
|
||||||
constructor(input: {
|
constructor(input: {
|
||||||
manifest: AgentManifest | unknown;
|
manifest: AgentManifest | unknown;
|
||||||
@@ -99,6 +156,8 @@ export class SchemaDrivenExecutionEngine {
|
|||||||
maxDepth: input.settings?.maxDepth ?? config.orchestration.maxDepth,
|
maxDepth: input.settings?.maxDepth ?? config.orchestration.maxDepth,
|
||||||
maxRetries: input.settings?.maxRetries ?? config.orchestration.maxRetries,
|
maxRetries: input.settings?.maxRetries ?? config.orchestration.maxRetries,
|
||||||
maxChildren: input.settings?.maxChildren ?? config.orchestration.maxChildren,
|
maxChildren: input.settings?.maxChildren ?? config.orchestration.maxChildren,
|
||||||
|
securityViolationHandling:
|
||||||
|
input.settings?.securityViolationHandling ?? config.security.violationHandling,
|
||||||
runtimeContext: {
|
runtimeContext: {
|
||||||
...(input.settings?.runtimeContext ?? {}),
|
...(input.settings?.runtimeContext ?? {}),
|
||||||
},
|
},
|
||||||
@@ -120,6 +179,10 @@ export class SchemaDrivenExecutionEngine {
|
|||||||
maxRecursiveDepth: config.agentManager.maxRecursiveDepth,
|
maxRecursiveDepth: config.agentManager.maxRecursiveDepth,
|
||||||
});
|
});
|
||||||
this.mcpRegistry = input.mcpRegistry ?? createDefaultMcpRegistry();
|
this.mcpRegistry = input.mcpRegistry ?? createDefaultMcpRegistry();
|
||||||
|
this.securityContext = createActorSecurityContext({
|
||||||
|
config,
|
||||||
|
settings: this.settings,
|
||||||
|
});
|
||||||
|
|
||||||
for (const persona of this.manifest.personas) {
|
for (const persona of this.manifest.personas) {
|
||||||
this.personaRegistry.register({
|
this.personaRegistry.register({
|
||||||
@@ -198,6 +261,8 @@ export class SchemaDrivenExecutionEngine {
|
|||||||
managerSessionId,
|
managerSessionId,
|
||||||
projectContextStore: this.projectContextStore,
|
projectContextStore: this.projectContextStore,
|
||||||
mcpRegistry: this.mcpRegistry,
|
mcpRegistry: this.mcpRegistry,
|
||||||
|
securityViolationHandling: this.settings.securityViolationHandling,
|
||||||
|
securityContext: this.securityContext,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { JsonObject } from "./types.js";
|
import type { JsonObject } from "./types.js";
|
||||||
import type { ManifestPersona, ToolClearancePolicy } from "./manifest.js";
|
import type { ManifestPersona, ToolClearancePolicy } from "./manifest.js";
|
||||||
|
import { parseToolClearancePolicy } from "../security/schemas.js";
|
||||||
|
|
||||||
export type PersonaBehaviorEvent = "onTaskComplete" | "onValidationFail";
|
export type PersonaBehaviorEvent = "onTaskComplete" | "onValidationFail";
|
||||||
|
|
||||||
@@ -28,18 +29,6 @@ function renderTemplate(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueStrings(values: string[]): string[] {
|
|
||||||
const output: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const value of values) {
|
|
||||||
if (!seen.has(value)) {
|
|
||||||
output.push(value);
|
|
||||||
seen.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PersonaRegistry {
|
export class PersonaRegistry {
|
||||||
private readonly personas = new Map<string, PersonaRuntimeDefinition>();
|
private readonly personas = new Map<string, PersonaRuntimeDefinition>();
|
||||||
|
|
||||||
@@ -54,12 +43,10 @@ export class PersonaRegistry {
|
|||||||
throw new Error(`Persona \"${persona.id}\" is already registered.`);
|
throw new Error(`Persona \"${persona.id}\" is already registered.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toolClearance = parseToolClearancePolicy(persona.toolClearance);
|
||||||
this.personas.set(persona.id, {
|
this.personas.set(persona.id, {
|
||||||
...persona,
|
...persona,
|
||||||
toolClearance: {
|
toolClearance,
|
||||||
allowlist: uniqueStrings(persona.toolClearance.allowlist),
|
|
||||||
banlist: uniqueStrings(persona.toolClearance.banlist),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +68,6 @@ export class PersonaRegistry {
|
|||||||
|
|
||||||
getToolClearance(personaId: string): ToolClearancePolicy {
|
getToolClearance(personaId: string): ToolClearancePolicy {
|
||||||
const persona = this.getById(personaId);
|
const persona = this.getById(personaId);
|
||||||
|
|
||||||
// TODO(security): enforce allowlist/banlist in the tool execution boundary.
|
|
||||||
return {
|
return {
|
||||||
allowlist: [...persona.toolClearance.allowlist],
|
allowlist: [...persona.toolClearance.allowlist],
|
||||||
banlist: [...persona.toolClearance.banlist],
|
banlist: [...persona.toolClearance.banlist],
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import {
|
|||||||
type StoredSessionState,
|
type StoredSessionState,
|
||||||
} from "./state-context.js";
|
} from "./state-context.js";
|
||||||
import type { JsonObject } from "./types.js";
|
import type { JsonObject } from "./types.js";
|
||||||
|
import {
|
||||||
|
SecureCommandExecutor,
|
||||||
|
SecurityRulesEngine,
|
||||||
|
SecurityViolationError,
|
||||||
|
type ExecutionEnvPolicy,
|
||||||
|
type SecurityViolationHandling,
|
||||||
|
} from "../security/index.js";
|
||||||
|
|
||||||
export type ActorResultStatus = "success" | "validation_fail" | "failure";
|
export type ActorResultStatus = "success" | "validation_fail" | "failure";
|
||||||
export type ActorFailureKind = "soft" | "hard";
|
export type ActorFailureKind = "soft" | "hard";
|
||||||
@@ -50,6 +57,7 @@ export type ActorExecutionInput = {
|
|||||||
allowlist: string[];
|
allowlist: string[];
|
||||||
banlist: string[];
|
banlist: string[];
|
||||||
};
|
};
|
||||||
|
security?: ActorExecutionSecurityContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActorExecutor = (input: ActorExecutionInput) => Promise<ActorExecutionResult>;
|
export type ActorExecutor = (input: ActorExecutionInput) => Promise<ActorExecutionResult>;
|
||||||
@@ -84,6 +92,19 @@ export type PipelineExecutorOptions = {
|
|||||||
failurePolicy?: FailurePolicy;
|
failurePolicy?: FailurePolicy;
|
||||||
lifecycleObserver?: PipelineLifecycleObserver;
|
lifecycleObserver?: PipelineLifecycleObserver;
|
||||||
hardFailureThreshold?: number;
|
hardFailureThreshold?: number;
|
||||||
|
securityViolationHandling?: SecurityViolationHandling;
|
||||||
|
securityContext?: ActorExecutionSecurityContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActorExecutionSecurityContext = {
|
||||||
|
rulesEngine: SecurityRulesEngine;
|
||||||
|
createCommandExecutor: (input?: {
|
||||||
|
timeoutMs?: number;
|
||||||
|
envPolicy?: ExecutionEnvPolicy;
|
||||||
|
shellPath?: string;
|
||||||
|
uid?: number;
|
||||||
|
gid?: number;
|
||||||
|
}) => SecureCommandExecutor;
|
||||||
};
|
};
|
||||||
|
|
||||||
type QueueItem = {
|
type QueueItem = {
|
||||||
@@ -283,6 +304,8 @@ export class PipelineExecutor {
|
|||||||
private readonly failurePolicy: FailurePolicy;
|
private readonly failurePolicy: FailurePolicy;
|
||||||
private readonly lifecycleObserver: PipelineLifecycleObserver;
|
private readonly lifecycleObserver: PipelineLifecycleObserver;
|
||||||
private readonly hardFailureThreshold: number;
|
private readonly hardFailureThreshold: number;
|
||||||
|
private readonly securityViolationHandling: SecurityViolationHandling;
|
||||||
|
private readonly securityContext?: ActorExecutionSecurityContext;
|
||||||
private managerRunCounter = 0;
|
private managerRunCounter = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -294,6 +317,8 @@ export class PipelineExecutor {
|
|||||||
) {
|
) {
|
||||||
this.failurePolicy = options.failurePolicy ?? new FailurePolicy();
|
this.failurePolicy = options.failurePolicy ?? new FailurePolicy();
|
||||||
this.hardFailureThreshold = options.hardFailureThreshold ?? 2;
|
this.hardFailureThreshold = options.hardFailureThreshold ?? 2;
|
||||||
|
this.securityViolationHandling = options.securityViolationHandling ?? "hard_abort";
|
||||||
|
this.securityContext = options.securityContext;
|
||||||
this.lifecycleObserver =
|
this.lifecycleObserver =
|
||||||
options.lifecycleObserver ??
|
options.lifecycleObserver ??
|
||||||
new PersistenceLifecycleObserver({
|
new PersistenceLifecycleObserver({
|
||||||
@@ -719,12 +744,29 @@ export class PipelineExecutor {
|
|||||||
context: input.context,
|
context: input.context,
|
||||||
signal: input.signal,
|
signal: input.signal,
|
||||||
toolClearance: this.personaRegistry.getToolClearance(input.node.personaId),
|
toolClearance: this.personaRegistry.getToolClearance(input.node.personaId),
|
||||||
|
security: this.securityContext,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (input.signal.aborted) {
|
if (input.signal.aborted) {
|
||||||
throw toAbortError(input.signal);
|
throw toAbortError(input.signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error instanceof SecurityViolationError) {
|
||||||
|
if (this.securityViolationHandling === "hard_abort") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "validation_fail",
|
||||||
|
payload: {
|
||||||
|
error: error.message,
|
||||||
|
security_violation: true,
|
||||||
|
},
|
||||||
|
failureCode: error.code,
|
||||||
|
failureKind: "soft",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const classified = this.failurePolicy.classifyFailureFromError(error);
|
const classified = this.failurePolicy.classifyFailureFromError(error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
105
src/config.ts
105
src/config.ts
@@ -1,5 +1,6 @@
|
|||||||
import type { AgentManagerLimits } from "./agents/manager.js";
|
import type { AgentManagerLimits } from "./agents/manager.js";
|
||||||
import type { BuiltInProvisioningConfig } from "./agents/provisioning.js";
|
import type { BuiltInProvisioningConfig } from "./agents/provisioning.js";
|
||||||
|
import { parseSecurityViolationHandling, type SecurityViolationHandling } from "./security/index.js";
|
||||||
|
|
||||||
export type ProviderRuntimeConfig = {
|
export type ProviderRuntimeConfig = {
|
||||||
codexApiKey?: string;
|
codexApiKey?: string;
|
||||||
@@ -27,6 +28,17 @@ export type DiscoveryRuntimeConfig = {
|
|||||||
fileRelativePath: string;
|
fileRelativePath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SecurityRuntimeConfig = {
|
||||||
|
violationHandling: SecurityViolationHandling;
|
||||||
|
shellAllowedBinaries: string[];
|
||||||
|
commandTimeoutMs: number;
|
||||||
|
auditLogPath: string;
|
||||||
|
inheritedEnvVars: string[];
|
||||||
|
scrubbedEnvVars: string[];
|
||||||
|
dropUid?: number;
|
||||||
|
dropGid?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AppConfig = {
|
export type AppConfig = {
|
||||||
provider: ProviderRuntimeConfig;
|
provider: ProviderRuntimeConfig;
|
||||||
mcp: McpRuntimeConfig;
|
mcp: McpRuntimeConfig;
|
||||||
@@ -34,6 +46,7 @@ export type AppConfig = {
|
|||||||
orchestration: OrchestrationRuntimeConfig;
|
orchestration: OrchestrationRuntimeConfig;
|
||||||
provisioning: BuiltInProvisioningConfig;
|
provisioning: BuiltInProvisioningConfig;
|
||||||
discovery: DiscoveryRuntimeConfig;
|
discovery: DiscoveryRuntimeConfig;
|
||||||
|
security: SecurityRuntimeConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_AGENT_MANAGER: AgentManagerLimits = {
|
const DEFAULT_AGENT_MANAGER: AgentManagerLimits = {
|
||||||
@@ -68,6 +81,15 @@ const DEFAULT_DISCOVERY: DiscoveryRuntimeConfig = {
|
|||||||
fileRelativePath: ".agent-context/resources.json",
|
fileRelativePath: ".agent-context/resources.json",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SECURITY: SecurityRuntimeConfig = {
|
||||||
|
violationHandling: "hard_abort",
|
||||||
|
shellAllowedBinaries: ["git", "npm", "node", "cat", "ls", "pwd", "echo", "bash", "sh"],
|
||||||
|
commandTimeoutMs: 120_000,
|
||||||
|
auditLogPath: ".ai_ops/security/command-audit.ndjson",
|
||||||
|
inheritedEnvVars: ["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL"],
|
||||||
|
scrubbedEnvVars: [],
|
||||||
|
};
|
||||||
|
|
||||||
function readOptionalString(
|
function readOptionalString(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
key: string,
|
key: string,
|
||||||
@@ -87,6 +109,31 @@ function readStringWithFallback(
|
|||||||
return readOptionalString(env, key) ?? fallback;
|
return readOptionalString(env, key) ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCsvStringArrayWithFallback(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
key: string,
|
||||||
|
fallback: readonly string[],
|
||||||
|
options: {
|
||||||
|
allowEmpty?: boolean;
|
||||||
|
} = {},
|
||||||
|
): string[] {
|
||||||
|
const raw = env[key]?.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = raw
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
|
||||||
|
if (parsed.length === 0 && options.allowEmpty !== true) {
|
||||||
|
throw new Error(`Environment variable ${key} must include at least one comma-delimited value.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
function readIntegerWithBounds(
|
function readIntegerWithBounds(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
key: string,
|
key: string,
|
||||||
@@ -108,6 +155,26 @@ function readIntegerWithBounds(
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalIntegerWithBounds(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
key: string,
|
||||||
|
bounds: {
|
||||||
|
min: number;
|
||||||
|
},
|
||||||
|
): number | undefined {
|
||||||
|
const raw = env[key]?.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < bounds.min) {
|
||||||
|
throw new Error(`Environment variable ${key} must be an integer >= ${String(bounds.min)}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
function readBooleanWithFallback(
|
function readBooleanWithFallback(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
key: string,
|
key: string,
|
||||||
@@ -142,6 +209,12 @@ function deepFreeze<T>(value: T): Readonly<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Readonly<AppConfig> {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Readonly<AppConfig> {
|
||||||
|
const rawViolationHandling = readStringWithFallback(
|
||||||
|
env,
|
||||||
|
"AGENT_SECURITY_VIOLATION_MODE",
|
||||||
|
DEFAULT_SECURITY.violationHandling,
|
||||||
|
);
|
||||||
|
|
||||||
const config: AppConfig = {
|
const config: AppConfig = {
|
||||||
provider: {
|
provider: {
|
||||||
codexApiKey: readOptionalString(env, "CODEX_API_KEY"),
|
codexApiKey: readOptionalString(env, "CODEX_API_KEY"),
|
||||||
@@ -257,6 +330,38 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Readonly<AppCo
|
|||||||
DEFAULT_DISCOVERY.fileRelativePath,
|
DEFAULT_DISCOVERY.fileRelativePath,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
security: {
|
||||||
|
violationHandling: parseSecurityViolationHandling(rawViolationHandling),
|
||||||
|
shellAllowedBinaries: readCsvStringArrayWithFallback(
|
||||||
|
env,
|
||||||
|
"AGENT_SECURITY_ALLOWED_BINARIES",
|
||||||
|
DEFAULT_SECURITY.shellAllowedBinaries,
|
||||||
|
),
|
||||||
|
commandTimeoutMs: readIntegerWithBounds(
|
||||||
|
env,
|
||||||
|
"AGENT_SECURITY_COMMAND_TIMEOUT_MS",
|
||||||
|
DEFAULT_SECURITY.commandTimeoutMs,
|
||||||
|
{ min: 1 },
|
||||||
|
),
|
||||||
|
auditLogPath: readStringWithFallback(
|
||||||
|
env,
|
||||||
|
"AGENT_SECURITY_AUDIT_LOG_PATH",
|
||||||
|
DEFAULT_SECURITY.auditLogPath,
|
||||||
|
),
|
||||||
|
inheritedEnvVars: readCsvStringArrayWithFallback(
|
||||||
|
env,
|
||||||
|
"AGENT_SECURITY_ENV_INHERIT",
|
||||||
|
DEFAULT_SECURITY.inheritedEnvVars,
|
||||||
|
),
|
||||||
|
scrubbedEnvVars: readCsvStringArrayWithFallback(
|
||||||
|
env,
|
||||||
|
"AGENT_SECURITY_ENV_SCRUB",
|
||||||
|
DEFAULT_SECURITY.scrubbedEnvVars,
|
||||||
|
{ allowEmpty: true },
|
||||||
|
),
|
||||||
|
dropUid: readOptionalIntegerWithBounds(env, "AGENT_SECURITY_DROP_UID", { min: 0 }),
|
||||||
|
dropGid: readOptionalIntegerWithBounds(env, "AGENT_SECURITY_DROP_GID", { min: 0 }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return deepFreeze(config);
|
return deepFreeze(config);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
McpLoadContext,
|
McpLoadContext,
|
||||||
SharedMcpConfigFile,
|
SharedMcpConfigFile,
|
||||||
} from "./mcp/types.js";
|
} from "./mcp/types.js";
|
||||||
|
import type { ToolClearancePolicy } from "./security/schemas.js";
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
@@ -62,6 +63,7 @@ export function loadMcpConfigFromEnv(
|
|||||||
options?: {
|
options?: {
|
||||||
config?: Readonly<AppConfig>;
|
config?: Readonly<AppConfig>;
|
||||||
registry?: McpRegistry;
|
registry?: McpRegistry;
|
||||||
|
toolClearance?: ToolClearancePolicy;
|
||||||
},
|
},
|
||||||
): LoadedMcpConfig {
|
): LoadedMcpConfig {
|
||||||
const runtimeConfig = options?.config ?? getConfig();
|
const runtimeConfig = options?.config ?? getConfig();
|
||||||
@@ -82,6 +84,7 @@ export function loadMcpConfigFromEnv(
|
|||||||
server,
|
server,
|
||||||
context,
|
context,
|
||||||
fullConfig: config,
|
fullConfig: config,
|
||||||
|
toolClearance: options?.toolClearance,
|
||||||
});
|
});
|
||||||
resolvedHandlers[serverName] = resolved.handlerId;
|
resolvedHandlers[serverName] = resolved.handlerId;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import type {
|
|||||||
SharedMcpConfigFile,
|
SharedMcpConfigFile,
|
||||||
SharedMcpServer,
|
SharedMcpServer,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
import {
|
||||||
|
parseToolClearancePolicy,
|
||||||
|
type ToolClearancePolicy,
|
||||||
|
} from "../security/schemas.js";
|
||||||
|
|
||||||
export type McpHandlerUtils = {
|
export type McpHandlerUtils = {
|
||||||
inferTransport: typeof inferTransport;
|
inferTransport: typeof inferTransport;
|
||||||
@@ -114,6 +118,77 @@ function readBooleanConfigValue(
|
|||||||
return typeof value === "boolean" ? value : undefined;
|
return typeof value === "boolean" ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStringArray(value: unknown): string[] | undefined {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: string[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (typeof item !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = item.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupe(values: string[]): string[] {
|
||||||
|
const output: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const value of values) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
output.push(value);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToolClearanceToResult(
|
||||||
|
result: McpHandlerResult,
|
||||||
|
toolClearance: ToolClearancePolicy,
|
||||||
|
): McpHandlerResult {
|
||||||
|
if (!result.codex) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codexConfig = result.codex;
|
||||||
|
const existingEnabled = readStringArray(codexConfig.enabled_tools);
|
||||||
|
const existingDisabled = readStringArray(codexConfig.disabled_tools) ?? [];
|
||||||
|
const banlist = new Set(toolClearance.banlist);
|
||||||
|
|
||||||
|
let enabledTools = existingEnabled;
|
||||||
|
if (toolClearance.allowlist.length > 0) {
|
||||||
|
enabledTools = (enabledTools ?? toolClearance.allowlist).filter((tool) =>
|
||||||
|
toolClearance.allowlist.includes(tool),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabledTools) {
|
||||||
|
enabledTools = enabledTools.filter((tool) => !banlist.has(tool));
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledTools = dedupe([...existingDisabled, ...toolClearance.banlist]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
codex: {
|
||||||
|
...codexConfig,
|
||||||
|
...(enabledTools ? { enabled_tools: enabledTools } : {}),
|
||||||
|
...(disabledTools.length > 0 ? { disabled_tools: disabledTools } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function applyEnabledByDefault(input: McpHandlerBusinessLogicInput): McpHandlerResult {
|
function applyEnabledByDefault(input: McpHandlerBusinessLogicInput): McpHandlerResult {
|
||||||
if (input.server.enabled !== undefined) {
|
if (input.server.enabled !== undefined) {
|
||||||
return input.baseResult;
|
return input.baseResult;
|
||||||
@@ -187,8 +262,9 @@ export class McpRegistry {
|
|||||||
server: SharedMcpServer;
|
server: SharedMcpServer;
|
||||||
context: McpLoadContext;
|
context: McpLoadContext;
|
||||||
fullConfig: SharedMcpConfigFile;
|
fullConfig: SharedMcpConfigFile;
|
||||||
|
toolClearance?: ToolClearancePolicy;
|
||||||
}): McpHandlerResult & { handlerId: string } {
|
}): McpHandlerResult & { handlerId: string } {
|
||||||
const { serverName, server, context, fullConfig } = input;
|
const { serverName, server, context, fullConfig, toolClearance } = input;
|
||||||
const handler = this.resolveHandler(serverName, server);
|
const handler = this.resolveHandler(serverName, server);
|
||||||
const handlerConfig = {
|
const handlerConfig = {
|
||||||
...(fullConfig.handlerSettings?.[handler.id] ?? {}),
|
...(fullConfig.handlerSettings?.[handler.id] ?? {}),
|
||||||
@@ -203,9 +279,12 @@ export class McpRegistry {
|
|||||||
fullConfig,
|
fullConfig,
|
||||||
utils,
|
utils,
|
||||||
});
|
});
|
||||||
|
const securedResult = toolClearance
|
||||||
|
? applyToolClearanceToResult(result, parseToolClearancePolicy(toolClearance))
|
||||||
|
: result;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...securedResult,
|
||||||
handlerId: handler.id,
|
handlerId: handler.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/security/audit-log.ts
Normal file
14
src/security/audit-log.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { mkdir, appendFile } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import type { SecurityAuditEvent, SecurityAuditSink } from "./rules-engine.js";
|
||||||
|
|
||||||
|
export function createFileSecurityAuditSink(filePath: string): SecurityAuditSink {
|
||||||
|
const resolvedPath = resolve(filePath);
|
||||||
|
|
||||||
|
return (event: SecurityAuditEvent): void => {
|
||||||
|
void (async () => {
|
||||||
|
await mkdir(dirname(resolvedPath), { recursive: true });
|
||||||
|
await appendFile(resolvedPath, `${JSON.stringify(event)}\n`, "utf8");
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
}
|
||||||
18
src/security/errors.ts
Normal file
18
src/security/errors.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export class SecurityViolationError extends Error {
|
||||||
|
readonly code: string;
|
||||||
|
readonly details?: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
input: {
|
||||||
|
code?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
cause?: unknown;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
super(message, input.cause !== undefined ? { cause: input.cause } : undefined);
|
||||||
|
this.name = "SecurityViolationError";
|
||||||
|
this.code = input.code ?? "SECURITY_VIOLATION";
|
||||||
|
this.details = input.details;
|
||||||
|
}
|
||||||
|
}
|
||||||
176
src/security/executor.ts
Normal file
176
src/security/executor.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import type { ParsedShellScript } from "./shell-parser.js";
|
||||||
|
import {
|
||||||
|
parseExecutionEnvPolicy,
|
||||||
|
type ExecutionEnvPolicy,
|
||||||
|
type ToolClearancePolicy,
|
||||||
|
} from "./schemas.js";
|
||||||
|
import { SecurityRulesEngine } from "./rules-engine.js";
|
||||||
|
|
||||||
|
export type SecureCommandExecutionResult = {
|
||||||
|
exitCode: number | null;
|
||||||
|
signal: NodeJS.Signals | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
timedOut: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
parsed: ParsedShellScript;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SecureCommandExecutorOptions = {
|
||||||
|
rulesEngine: SecurityRulesEngine;
|
||||||
|
envPolicy?: ExecutionEnvPolicy;
|
||||||
|
timeoutMs?: number;
|
||||||
|
shellPath?: string;
|
||||||
|
uid?: number;
|
||||||
|
gid?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SecureCommandExecutor {
|
||||||
|
private readonly rulesEngine: SecurityRulesEngine;
|
||||||
|
private readonly envPolicy: ExecutionEnvPolicy;
|
||||||
|
private readonly timeoutMs: number;
|
||||||
|
private readonly shellPath: string;
|
||||||
|
private readonly uid?: number;
|
||||||
|
private readonly gid?: number;
|
||||||
|
|
||||||
|
constructor(options: SecureCommandExecutorOptions) {
|
||||||
|
this.rulesEngine = options.rulesEngine;
|
||||||
|
this.envPolicy = parseExecutionEnvPolicy(options.envPolicy ?? {});
|
||||||
|
this.timeoutMs = options.timeoutMs ?? 120_000;
|
||||||
|
this.shellPath = options.shellPath ?? "/bin/bash";
|
||||||
|
this.uid = options.uid;
|
||||||
|
this.gid = options.gid;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(input: {
|
||||||
|
command: string;
|
||||||
|
cwd: string;
|
||||||
|
baseEnv?: Record<string, string | undefined>;
|
||||||
|
injectedEnv?: Record<string, string>;
|
||||||
|
toolClearance?: ToolClearancePolicy;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onStdoutChunk?: (chunk: string) => void;
|
||||||
|
onStderrChunk?: (chunk: string) => void;
|
||||||
|
}): Promise<SecureCommandExecutionResult> {
|
||||||
|
const validated = this.rulesEngine.validateShellCommand({
|
||||||
|
command: input.command,
|
||||||
|
cwd: input.cwd,
|
||||||
|
toolClearance: input.toolClearance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const env = this.buildExecutionEnv(input.baseEnv, input.injectedEnv);
|
||||||
|
|
||||||
|
return new Promise<SecureCommandExecutionResult>((resolvePromise, rejectPromise) => {
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let settled = false;
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
const settleResolve = (result: SecureCommandExecutionResult): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
resolvePromise(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const settleReject = (error: unknown): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
rejectPromise(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const child = spawn(this.shellPath, ["-c", input.command], {
|
||||||
|
cwd: validated.cwd,
|
||||||
|
env,
|
||||||
|
...(this.uid !== undefined ? { uid: this.uid } : {}),
|
||||||
|
...(this.gid !== undefined ? { gid: this.gid } : {}),
|
||||||
|
...(input.signal ? { signal: input.signal } : {}),
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutHandle =
|
||||||
|
this.timeoutMs > 0
|
||||||
|
? setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}, this.timeoutMs)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
const text = String(chunk);
|
||||||
|
stdout += text;
|
||||||
|
input.onStdoutChunk?.(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
const text = String(chunk);
|
||||||
|
stderr += text;
|
||||||
|
input.onStderrChunk?.(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
settleReject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (exitCode, signal) => {
|
||||||
|
if (timeoutHandle) {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timedOut) {
|
||||||
|
settleReject(
|
||||||
|
new Error(
|
||||||
|
`Command timed out after ${String(this.timeoutMs)}ms: ${input.command}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settleResolve({
|
||||||
|
exitCode,
|
||||||
|
signal,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
timedOut,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
parsed: validated.parsed,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildExecutionEnv(
|
||||||
|
baseEnv: Record<string, string | undefined> | undefined,
|
||||||
|
injectedEnv: Record<string, string> | undefined,
|
||||||
|
): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
const source = baseEnv ?? process.env;
|
||||||
|
|
||||||
|
for (const key of this.envPolicy.inherit) {
|
||||||
|
const value = source[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of this.envPolicy.scrub) {
|
||||||
|
delete out[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(out, this.envPolicy.inject);
|
||||||
|
|
||||||
|
if (injectedEnv) {
|
||||||
|
Object.assign(out, injectedEnv);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/security/index.ts
Normal file
33
src/security/index.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export { SecurityViolationError } from "./errors.js";
|
||||||
|
export {
|
||||||
|
parseExecutionEnvPolicy,
|
||||||
|
parseSecurityViolationHandling,
|
||||||
|
parseShellValidationPolicy,
|
||||||
|
parseToolClearancePolicy,
|
||||||
|
securityViolationHandlingSchema,
|
||||||
|
shellValidationPolicySchema,
|
||||||
|
toolClearancePolicySchema,
|
||||||
|
executionEnvPolicySchema,
|
||||||
|
type ExecutionEnvPolicy,
|
||||||
|
type SecurityViolationHandling,
|
||||||
|
type ShellValidationPolicy,
|
||||||
|
type ToolClearancePolicy,
|
||||||
|
} from "./schemas.js";
|
||||||
|
export {
|
||||||
|
parseShellScript,
|
||||||
|
type ParsedShellAssignment,
|
||||||
|
type ParsedShellCommand,
|
||||||
|
type ParsedShellScript,
|
||||||
|
} from "./shell-parser.js";
|
||||||
|
export {
|
||||||
|
SecurityRulesEngine,
|
||||||
|
type SecurityAuditEvent,
|
||||||
|
type SecurityAuditSink,
|
||||||
|
type ValidatedShellCommand,
|
||||||
|
} from "./rules-engine.js";
|
||||||
|
export {
|
||||||
|
SecureCommandExecutor,
|
||||||
|
type SecureCommandExecutionResult,
|
||||||
|
type SecureCommandExecutorOptions,
|
||||||
|
} from "./executor.js";
|
||||||
|
export { createFileSecurityAuditSink } from "./audit-log.js";
|
||||||
421
src/security/rules-engine.ts
Normal file
421
src/security/rules-engine.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
||||||
|
import { SecurityViolationError } from "./errors.js";
|
||||||
|
import {
|
||||||
|
parseShellScript,
|
||||||
|
type ParsedShellCommand,
|
||||||
|
type ParsedShellScript,
|
||||||
|
} from "./shell-parser.js";
|
||||||
|
import {
|
||||||
|
parseShellValidationPolicy,
|
||||||
|
parseToolClearancePolicy,
|
||||||
|
type ShellValidationPolicy,
|
||||||
|
type ToolClearancePolicy,
|
||||||
|
} from "./schemas.js";
|
||||||
|
|
||||||
|
export type SecurityAuditEvent =
|
||||||
|
| {
|
||||||
|
type: "shell.command_profiled";
|
||||||
|
timestamp: string;
|
||||||
|
command: string;
|
||||||
|
cwd: string;
|
||||||
|
parsed: ParsedShellScript;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "shell.command_allowed";
|
||||||
|
timestamp: string;
|
||||||
|
command: string;
|
||||||
|
cwd: string;
|
||||||
|
commandCount: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "shell.command_blocked";
|
||||||
|
timestamp: string;
|
||||||
|
command: string;
|
||||||
|
cwd: string;
|
||||||
|
reason: string;
|
||||||
|
code: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "tool.invocation_allowed";
|
||||||
|
timestamp: string;
|
||||||
|
tool: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "tool.invocation_blocked";
|
||||||
|
timestamp: string;
|
||||||
|
tool: string;
|
||||||
|
reason: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SecurityAuditSink = (event: SecurityAuditEvent) => void;
|
||||||
|
|
||||||
|
export type ValidatedShellCommand = {
|
||||||
|
cwd: string;
|
||||||
|
parsed: ParsedShellScript;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeToken(value: string): string {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPathTraversalSegment(token: string): boolean {
|
||||||
|
const normalized = token.replaceAll("\\", "/");
|
||||||
|
if (normalized === ".." || normalized.startsWith("../") || normalized.endsWith("/..")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return normalized.includes("/../");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathLikeToken(token: string): boolean {
|
||||||
|
if (!token || token === ".") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.startsWith("/") || token.startsWith("./") || token.startsWith("../")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token.includes("/") || token.includes("\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinPath(root: string, candidate: string): boolean {
|
||||||
|
const rel = relative(root, candidate);
|
||||||
|
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathBlocked(candidatePath: string, protectedPath: string): boolean {
|
||||||
|
const rel = relative(protectedPath, candidatePath);
|
||||||
|
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toToolSet(values: readonly string[]): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const value of values) {
|
||||||
|
out.add(value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNow(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SecurityRulesEngine {
|
||||||
|
private readonly policy: ShellValidationPolicy;
|
||||||
|
private readonly allowedBinaries: Set<string>;
|
||||||
|
private readonly allowedEnvAssignments: Set<string>;
|
||||||
|
private readonly blockedEnvAssignments: Set<string>;
|
||||||
|
private readonly worktreeRoot: string;
|
||||||
|
private readonly protectedPaths: string[];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
policy: ShellValidationPolicy,
|
||||||
|
private readonly auditSink?: SecurityAuditSink,
|
||||||
|
) {
|
||||||
|
this.policy = parseShellValidationPolicy(policy);
|
||||||
|
this.allowedBinaries = toToolSet(this.policy.allowedBinaries);
|
||||||
|
this.allowedEnvAssignments = toToolSet(this.policy.allowedEnvAssignments);
|
||||||
|
this.blockedEnvAssignments = toToolSet(this.policy.blockedEnvAssignments);
|
||||||
|
this.worktreeRoot = resolve(this.policy.worktreeRoot);
|
||||||
|
this.protectedPaths = this.policy.protectedPaths.map((path) => resolve(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
getPolicy(): ShellValidationPolicy {
|
||||||
|
return {
|
||||||
|
...this.policy,
|
||||||
|
allowedBinaries: [...this.policy.allowedBinaries],
|
||||||
|
protectedPaths: [...this.policy.protectedPaths],
|
||||||
|
allowedEnvAssignments: [...this.policy.allowedEnvAssignments],
|
||||||
|
blockedEnvAssignments: [...this.policy.blockedEnvAssignments],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validateShellCommand(input: {
|
||||||
|
command: string;
|
||||||
|
cwd: string;
|
||||||
|
toolClearance?: ToolClearancePolicy;
|
||||||
|
}): ValidatedShellCommand {
|
||||||
|
const resolvedCwd = resolve(input.cwd);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.assertCwdBoundary(resolvedCwd);
|
||||||
|
const parsed = parseShellScript(input.command);
|
||||||
|
const toolClearance = input.toolClearance
|
||||||
|
? parseToolClearancePolicy(input.toolClearance)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "shell.command_profiled",
|
||||||
|
timestamp: toNow(),
|
||||||
|
command: input.command,
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
parsed,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const command of parsed.commands) {
|
||||||
|
this.assertBinaryAllowed(command, toolClearance);
|
||||||
|
this.assertAssignmentsAllowed(command);
|
||||||
|
this.assertArgumentPaths(command, resolvedCwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "shell.command_allowed",
|
||||||
|
timestamp: toNow(),
|
||||||
|
command: input.command,
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
commandCount: parsed.commandCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
parsed,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SecurityViolationError) {
|
||||||
|
this.emit({
|
||||||
|
type: "shell.command_blocked",
|
||||||
|
timestamp: toNow(),
|
||||||
|
command: input.command,
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
reason: error.message,
|
||||||
|
code: error.code,
|
||||||
|
details: error.details,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new SecurityViolationError("Unexpected error while validating shell command.", {
|
||||||
|
code: "SECURITY_VALIDATION_FAILED",
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertToolInvocationAllowed(input: {
|
||||||
|
tool: string;
|
||||||
|
toolClearance: ToolClearancePolicy;
|
||||||
|
}): void {
|
||||||
|
const policy = parseToolClearancePolicy(input.toolClearance);
|
||||||
|
|
||||||
|
if (policy.banlist.includes(input.tool)) {
|
||||||
|
this.emit({
|
||||||
|
type: "tool.invocation_blocked",
|
||||||
|
timestamp: toNow(),
|
||||||
|
tool: input.tool,
|
||||||
|
reason: `Tool "${input.tool}" is explicitly banned by policy.`,
|
||||||
|
code: "TOOL_BANNED",
|
||||||
|
});
|
||||||
|
throw new SecurityViolationError(
|
||||||
|
`Tool "${input.tool}" is explicitly banned by policy.`,
|
||||||
|
{
|
||||||
|
code: "TOOL_BANNED",
|
||||||
|
details: {
|
||||||
|
tool: input.tool,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy.allowlist.length > 0 && !policy.allowlist.includes(input.tool)) {
|
||||||
|
this.emit({
|
||||||
|
type: "tool.invocation_blocked",
|
||||||
|
timestamp: toNow(),
|
||||||
|
tool: input.tool,
|
||||||
|
reason: `Tool "${input.tool}" is not present in allowlist.`,
|
||||||
|
code: "TOOL_NOT_ALLOWED",
|
||||||
|
});
|
||||||
|
throw new SecurityViolationError(
|
||||||
|
`Tool "${input.tool}" is not present in allowlist.`,
|
||||||
|
{
|
||||||
|
code: "TOOL_NOT_ALLOWED",
|
||||||
|
details: {
|
||||||
|
tool: input.tool,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "tool.invocation_allowed",
|
||||||
|
timestamp: toNow(),
|
||||||
|
tool: input.tool,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filterAllowedTools(tools: string[], toolClearance: ToolClearancePolicy): string[] {
|
||||||
|
const policy = parseToolClearancePolicy(toolClearance);
|
||||||
|
|
||||||
|
const allowedByAllowlist =
|
||||||
|
policy.allowlist.length === 0
|
||||||
|
? tools
|
||||||
|
: tools.filter((tool) => policy.allowlist.includes(tool));
|
||||||
|
|
||||||
|
return allowedByAllowlist.filter((tool) => !policy.banlist.includes(tool));
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCwdBoundary(cwd: string): void {
|
||||||
|
if (this.policy.requireCwdWithinWorktree && !isWithinPath(this.worktreeRoot, cwd)) {
|
||||||
|
throw new SecurityViolationError(
|
||||||
|
`cwd "${cwd}" is outside configured worktree root "${this.worktreeRoot}".`,
|
||||||
|
{
|
||||||
|
code: "CWD_OUTSIDE_WORKTREE",
|
||||||
|
details: {
|
||||||
|
cwd,
|
||||||
|
worktreeRoot: this.worktreeRoot,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const blockedPath of this.protectedPaths) {
|
||||||
|
if (!isPathBlocked(cwd, blockedPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new SecurityViolationError(
|
||||||
|
`cwd "${cwd}" is inside protected path "${blockedPath}".`,
|
||||||
|
{
|
||||||
|
code: "CWD_INSIDE_PROTECTED_PATH",
|
||||||
|
details: {
|
||||||
|
cwd,
|
||||||
|
protectedPath: blockedPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertBinaryAllowed(
|
||||||
|
command: ParsedShellCommand,
|
||||||
|
toolClearance?: ToolClearancePolicy,
|
||||||
|
): void {
|
||||||
|
const binaryToken = normalizeToken(command.binary);
|
||||||
|
const binaryName = basename(binaryToken);
|
||||||
|
|
||||||
|
if (!this.allowedBinaries.has(binaryToken) && !this.allowedBinaries.has(binaryName)) {
|
||||||
|
throw new SecurityViolationError(
|
||||||
|
`Binary "${command.binary}" is not in the shell allowlist.`,
|
||||||
|
{
|
||||||
|
code: "BINARY_NOT_ALLOWED",
|
||||||
|
details: {
|
||||||
|
binary: command.binary,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toolClearance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertToolInvocationAllowed({
|
||||||
|
tool: binaryName,
|
||||||
|
toolClearance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertAssignmentsAllowed(command: ParsedShellCommand): void {
|
||||||
|
for (const assignment of command.assignments) {
|
||||||
|
if (this.blockedEnvAssignments.has(assignment.key)) {
|
||||||
|
throw new SecurityViolationError(
|
||||||
|
`Environment assignment "${assignment.key}" is blocked by policy.`,
|
||||||
|
{
|
||||||
|
code: "ENV_ASSIGNMENT_BLOCKED",
|
||||||
|
details: {
|
||||||
|
key: assignment.key,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.allowedEnvAssignments.size > 0 &&
|
||||||
|
!this.allowedEnvAssignments.has(assignment.key)
|
||||||
|
) {
|
||||||
|
throw new SecurityViolationError(
|
||||||
|
`Environment assignment "${assignment.key}" is not in the assignment allowlist.`,
|
||||||
|
{
|
||||||
|
code: "ENV_ASSIGNMENT_NOT_ALLOWED",
|
||||||
|
details: {
|
||||||
|
key: assignment.key,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertArgumentPaths(command: ParsedShellCommand, cwd: string): void {
|
||||||
|
if (!this.policy.enforcePathBoundaryOnArguments) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const token of [...command.args, ...command.redirects]) {
|
||||||
|
if (!isPathLikeToken(token)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.policy.rejectRelativePathTraversal && hasPathTraversalSegment(token)) {
|
||||||
|
throw new SecurityViolationError(`Path traversal token "${token}" is blocked.`, {
|
||||||
|
code: "PATH_TRAVERSAL_BLOCKED",
|
||||||
|
details: {
|
||||||
|
token,
|
||||||
|
binary: command.binary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.startsWith("~")) {
|
||||||
|
throw new SecurityViolationError(
|
||||||
|
`Home-expansion path token "${token}" is blocked.`,
|
||||||
|
{
|
||||||
|
code: "HOME_EXPANSION_BLOCKED",
|
||||||
|
details: {
|
||||||
|
token,
|
||||||
|
binary: command.binary,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = isAbsolute(token) ? resolve(token) : resolve(cwd, token);
|
||||||
|
if (!isWithinPath(this.worktreeRoot, resolvedPath)) {
|
||||||
|
throw new SecurityViolationError(
|
||||||
|
`Path token "${token}" resolves outside worktree root.`,
|
||||||
|
{
|
||||||
|
code: "PATH_OUTSIDE_WORKTREE",
|
||||||
|
details: {
|
||||||
|
token,
|
||||||
|
resolvedPath,
|
||||||
|
worktreeRoot: this.worktreeRoot,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const protectedPath of this.protectedPaths) {
|
||||||
|
if (!isPathBlocked(resolvedPath, protectedPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new SecurityViolationError(
|
||||||
|
`Path token "${token}" resolves inside protected path.`,
|
||||||
|
{
|
||||||
|
code: "PATH_INSIDE_PROTECTED_PATH",
|
||||||
|
details: {
|
||||||
|
token,
|
||||||
|
resolvedPath,
|
||||||
|
protectedPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: SecurityAuditEvent): void {
|
||||||
|
this.auditSink?.(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/security/schemas.ts
Normal file
95
src/security/schemas.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
function dedupe(values: readonly string[]): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
out.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringTokenSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.refine((value) => !value.includes("\0"), "String token cannot include null bytes.");
|
||||||
|
|
||||||
|
export const toolClearancePolicySchema = z
|
||||||
|
.object({
|
||||||
|
allowlist: z.array(stringTokenSchema).default([]),
|
||||||
|
banlist: z.array(stringTokenSchema).default([]),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ToolClearancePolicy = z.infer<typeof toolClearancePolicySchema>;
|
||||||
|
|
||||||
|
export function parseToolClearancePolicy(input: unknown): ToolClearancePolicy {
|
||||||
|
const parsed = toolClearancePolicySchema.parse(input);
|
||||||
|
return {
|
||||||
|
allowlist: dedupe(parsed.allowlist),
|
||||||
|
banlist: dedupe(parsed.banlist),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shellValidationPolicySchema = z
|
||||||
|
.object({
|
||||||
|
allowedBinaries: z.array(stringTokenSchema).min(1),
|
||||||
|
worktreeRoot: stringTokenSchema,
|
||||||
|
protectedPaths: z.array(stringTokenSchema).default([]),
|
||||||
|
requireCwdWithinWorktree: z.boolean().default(true),
|
||||||
|
rejectRelativePathTraversal: z.boolean().default(true),
|
||||||
|
enforcePathBoundaryOnArguments: z.boolean().default(true),
|
||||||
|
allowedEnvAssignments: z.array(stringTokenSchema).default([]),
|
||||||
|
blockedEnvAssignments: z.array(stringTokenSchema).default([]),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ShellValidationPolicy = z.infer<typeof shellValidationPolicySchema>;
|
||||||
|
|
||||||
|
export function parseShellValidationPolicy(input: unknown): ShellValidationPolicy {
|
||||||
|
const parsed = shellValidationPolicySchema.parse(input);
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
allowedBinaries: dedupe(parsed.allowedBinaries),
|
||||||
|
protectedPaths: dedupe(parsed.protectedPaths),
|
||||||
|
allowedEnvAssignments: dedupe(parsed.allowedEnvAssignments),
|
||||||
|
blockedEnvAssignments: dedupe(parsed.blockedEnvAssignments),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const executionEnvPolicySchema = z
|
||||||
|
.object({
|
||||||
|
inherit: z.array(stringTokenSchema).default(["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL"]),
|
||||||
|
scrub: z.array(stringTokenSchema).default([]),
|
||||||
|
inject: z.record(z.string(), z.string()).default({}),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ExecutionEnvPolicy = z.infer<typeof executionEnvPolicySchema>;
|
||||||
|
|
||||||
|
export function parseExecutionEnvPolicy(input: unknown): ExecutionEnvPolicy {
|
||||||
|
const parsed = executionEnvPolicySchema.parse(input);
|
||||||
|
return {
|
||||||
|
inherit: dedupe(parsed.inherit),
|
||||||
|
scrub: dedupe(parsed.scrub),
|
||||||
|
inject: { ...parsed.inject },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SecurityViolationHandling = "hard_abort" | "validation_fail";
|
||||||
|
|
||||||
|
export const securityViolationHandlingSchema = z.union([
|
||||||
|
z.literal("hard_abort"),
|
||||||
|
z.literal("validation_fail"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function parseSecurityViolationHandling(input: unknown): SecurityViolationHandling {
|
||||||
|
return securityViolationHandlingSchema.parse(input);
|
||||||
|
}
|
||||||
180
src/security/shell-parser.ts
Normal file
180
src/security/shell-parser.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import parseBash from "bash-parser";
|
||||||
|
import { SecurityViolationError } from "./errors.js";
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type ParsedShellAssignment = {
|
||||||
|
raw: string;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedShellCommand = {
|
||||||
|
binary: string;
|
||||||
|
args: string[];
|
||||||
|
flags: string[];
|
||||||
|
assignments: ParsedShellAssignment[];
|
||||||
|
redirects: string[];
|
||||||
|
words: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedShellScript = {
|
||||||
|
commandCount: number;
|
||||||
|
commands: ParsedShellCommand[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is UnknownRecord {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTextNode(node: unknown): string | undefined {
|
||||||
|
if (!isRecord(node)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = readString(node.text);
|
||||||
|
if (!text) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAssignment(raw: string): ParsedShellAssignment | undefined {
|
||||||
|
const separatorIndex = raw.indexOf("=");
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = raw.slice(0, separatorIndex);
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
key,
|
||||||
|
value: raw.slice(separatorIndex + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toParsedCommand(node: UnknownRecord): ParsedShellCommand | undefined {
|
||||||
|
if (node.type !== "Command") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const binary = readTextNode(node.name)?.trim();
|
||||||
|
if (!binary) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args: string[] = [];
|
||||||
|
const assignments: ParsedShellAssignment[] = [];
|
||||||
|
const redirects: string[] = [];
|
||||||
|
|
||||||
|
const prefix = Array.isArray(node.prefix) ? node.prefix : [];
|
||||||
|
for (const entry of prefix) {
|
||||||
|
if (!isRecord(entry) || entry.type !== "AssignmentWord") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readString(entry.text);
|
||||||
|
if (!raw) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignment = parseAssignment(raw);
|
||||||
|
if (assignment) {
|
||||||
|
assignments.push(assignment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = Array.isArray(node.suffix) ? node.suffix : [];
|
||||||
|
for (const entry of suffix) {
|
||||||
|
if (!isRecord(entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === "Word") {
|
||||||
|
const text = readString(entry.text);
|
||||||
|
if (text) {
|
||||||
|
args.push(text);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type !== "Redirect") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileWord = readTextNode(entry.file);
|
||||||
|
if (fileWord) {
|
||||||
|
redirects.push(fileWord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
binary,
|
||||||
|
args,
|
||||||
|
flags: args.filter((arg) => arg.startsWith("-")),
|
||||||
|
assignments,
|
||||||
|
redirects,
|
||||||
|
words: [binary, ...args, ...redirects],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseShellScript(script: string): ParsedShellScript {
|
||||||
|
let ast: unknown;
|
||||||
|
try {
|
||||||
|
ast = parseBash(script);
|
||||||
|
} catch (error) {
|
||||||
|
throw new SecurityViolationError("Shell command failed AST parsing.", {
|
||||||
|
code: "SHELL_PARSE_FAILED",
|
||||||
|
cause: error,
|
||||||
|
details: {
|
||||||
|
script,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands: ParsedShellCommand[] = [];
|
||||||
|
const seen = new WeakSet<object>();
|
||||||
|
|
||||||
|
const visit = (node: unknown): void => {
|
||||||
|
if (!isRecord(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(node);
|
||||||
|
|
||||||
|
const parsedCommand = toParsedCommand(node);
|
||||||
|
if (parsedCommand) {
|
||||||
|
commands.push(parsedCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const value of Object.values(node)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
visit(item);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
visit(ast);
|
||||||
|
|
||||||
|
return {
|
||||||
|
commandCount: commands.length,
|
||||||
|
commands,
|
||||||
|
};
|
||||||
|
}
|
||||||
3
src/types/bash-parser.d.ts
vendored
Normal file
3
src/types/bash-parser.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
declare module "bash-parser" {
|
||||||
|
export default function parseBash(script: string): unknown;
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ test("loads defaults and freezes config", () => {
|
|||||||
assert.equal(config.orchestration.maxDepth, 4);
|
assert.equal(config.orchestration.maxDepth, 4);
|
||||||
assert.equal(config.provisioning.portRange.basePort, 36000);
|
assert.equal(config.provisioning.portRange.basePort, 36000);
|
||||||
assert.equal(config.discovery.fileRelativePath, ".agent-context/resources.json");
|
assert.equal(config.discovery.fileRelativePath, ".agent-context/resources.json");
|
||||||
|
assert.equal(config.security.violationHandling, "hard_abort");
|
||||||
|
assert.equal(config.security.commandTimeoutMs, 120000);
|
||||||
assert.equal(Object.isFrozen(config), true);
|
assert.equal(Object.isFrozen(config), true);
|
||||||
assert.equal(Object.isFrozen(config.orchestration), true);
|
assert.equal(Object.isFrozen(config.orchestration), true);
|
||||||
});
|
});
|
||||||
@@ -20,3 +22,9 @@ test("validates boolean env values", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validates security violation mode", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig({ AGENT_SECURITY_VIOLATION_MODE: "retry_forever" }),
|
||||||
|
/invalid_union|Invalid input/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,3 +63,34 @@ test("mcp registry rejects unknown explicit handlers", () => {
|
|||||||
/Unknown MCP handler/,
|
/Unknown MCP handler/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mcp registry enforces tool clearance on resolved codex tool lists", () => {
|
||||||
|
const registry = createDefaultMcpRegistry();
|
||||||
|
|
||||||
|
const resolved = registry.resolveServerWithHandler({
|
||||||
|
serverName: "sandbox-tools",
|
||||||
|
server: {
|
||||||
|
type: "stdio",
|
||||||
|
command: "node",
|
||||||
|
args: ["server.js"],
|
||||||
|
enabled_tools: ["read_file", "write_file", "search"],
|
||||||
|
disabled_tools: ["legacy_tool"],
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
fullConfig: {
|
||||||
|
servers: {},
|
||||||
|
},
|
||||||
|
toolClearance: {
|
||||||
|
allowlist: ["read_file", "search"],
|
||||||
|
banlist: ["search", "write_file"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(resolved.codex);
|
||||||
|
assert.deepEqual(resolved.codex.enabled_tools, ["read_file"]);
|
||||||
|
assert.deepEqual(resolved.codex.disabled_tools, [
|
||||||
|
"legacy_tool",
|
||||||
|
"search",
|
||||||
|
"write_file",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { SchemaDrivenExecutionEngine } from "../src/agents/orchestration.js";
|
import { SchemaDrivenExecutionEngine } from "../src/agents/orchestration.js";
|
||||||
import type { ActorExecutionResult } from "../src/agents/pipeline.js";
|
import type { ActorExecutionResult } from "../src/agents/pipeline.js";
|
||||||
|
import { SecurityViolationError } from "../src/security/index.js";
|
||||||
|
|
||||||
function createManifest(): unknown {
|
function createManifest(): unknown {
|
||||||
return {
|
return {
|
||||||
@@ -191,6 +192,7 @@ test("runs DAG pipeline with state-dependent routing and retry behavior", async
|
|||||||
coder: async (input): Promise<ActorExecutionResult> => {
|
coder: async (input): Promise<ActorExecutionResult> => {
|
||||||
assert.match(input.prompt, /AIOPS-123/);
|
assert.match(input.prompt, /AIOPS-123/);
|
||||||
assert.deepEqual(input.toolClearance.allowlist, ["read_file", "write_file"]);
|
assert.deepEqual(input.toolClearance.allowlist, ["read_file", "write_file"]);
|
||||||
|
assert.ok(input.security);
|
||||||
coderAttempts += 1;
|
coderAttempts += 1;
|
||||||
if (coderAttempts === 1) {
|
if (coderAttempts === 1) {
|
||||||
return {
|
return {
|
||||||
@@ -759,3 +761,158 @@ test("propagates abort signal into actor execution and stops the run", async ()
|
|||||||
await assert.rejects(() => runPromise, /(AbortError|manual-abort|aborted)/i);
|
await assert.rejects(() => runPromise, /(AbortError|manual-abort|aborted)/i);
|
||||||
assert.equal(observedAbort, true);
|
assert.equal(observedAbort, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("hard-aborts pipeline on security violations by default", async () => {
|
||||||
|
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-workspace-"));
|
||||||
|
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-session-state-"));
|
||||||
|
const projectContextPath = resolve(stateRoot, "project-context.json");
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
schemaVersion: "1",
|
||||||
|
topologies: ["retry-unrolled", "sequential"],
|
||||||
|
personas: [
|
||||||
|
{
|
||||||
|
id: "coder",
|
||||||
|
displayName: "Coder",
|
||||||
|
systemPromptTemplate: "Coder",
|
||||||
|
toolClearance: {
|
||||||
|
allowlist: ["git"],
|
||||||
|
banlist: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relationships: [],
|
||||||
|
topologyConstraints: {
|
||||||
|
maxDepth: 3,
|
||||||
|
maxRetries: 2,
|
||||||
|
},
|
||||||
|
pipeline: {
|
||||||
|
entryNodeId: "secure-node",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "secure-node",
|
||||||
|
actorId: "secure_actor",
|
||||||
|
personaId: "coder",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const engine = new SchemaDrivenExecutionEngine({
|
||||||
|
manifest,
|
||||||
|
settings: {
|
||||||
|
workspaceRoot,
|
||||||
|
stateRoot,
|
||||||
|
projectContextPath,
|
||||||
|
maxDepth: 3,
|
||||||
|
maxRetries: 2,
|
||||||
|
maxChildren: 2,
|
||||||
|
runtimeContext: {},
|
||||||
|
},
|
||||||
|
actorExecutors: {
|
||||||
|
secure_actor: async () => {
|
||||||
|
throw new SecurityViolationError("blocked by policy", {
|
||||||
|
code: "TOOL_NOT_ALLOWED",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
engine.runSession({
|
||||||
|
sessionId: "session-security-hard-abort",
|
||||||
|
initialPayload: {
|
||||||
|
task: "Security hard abort",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/blocked by policy/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can map security violations to validation_fail for retry-unrolled remediation", async () => {
|
||||||
|
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-workspace-"));
|
||||||
|
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-session-state-"));
|
||||||
|
const projectContextPath = resolve(stateRoot, "project-context.json");
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
schemaVersion: "1",
|
||||||
|
topologies: ["retry-unrolled", "sequential"],
|
||||||
|
personas: [
|
||||||
|
{
|
||||||
|
id: "coder",
|
||||||
|
displayName: "Coder",
|
||||||
|
systemPromptTemplate: "Coder",
|
||||||
|
toolClearance: {
|
||||||
|
allowlist: ["git"],
|
||||||
|
banlist: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relationships: [],
|
||||||
|
topologyConstraints: {
|
||||||
|
maxDepth: 3,
|
||||||
|
maxRetries: 2,
|
||||||
|
},
|
||||||
|
pipeline: {
|
||||||
|
entryNodeId: "secure-node",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "secure-node",
|
||||||
|
actorId: "secure_actor",
|
||||||
|
personaId: "coder",
|
||||||
|
constraints: {
|
||||||
|
maxRetries: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const engine = new SchemaDrivenExecutionEngine({
|
||||||
|
manifest,
|
||||||
|
settings: {
|
||||||
|
workspaceRoot,
|
||||||
|
stateRoot,
|
||||||
|
projectContextPath,
|
||||||
|
maxDepth: 3,
|
||||||
|
maxRetries: 2,
|
||||||
|
maxChildren: 2,
|
||||||
|
securityViolationHandling: "validation_fail",
|
||||||
|
runtimeContext: {},
|
||||||
|
},
|
||||||
|
actorExecutors: {
|
||||||
|
secure_actor: async () => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts === 1) {
|
||||||
|
throw new SecurityViolationError("first attempt blocked", {
|
||||||
|
code: "PATH_TRAVERSAL_BLOCKED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
payload: {
|
||||||
|
fixed: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await engine.runSession({
|
||||||
|
sessionId: "session-security-validation-retry",
|
||||||
|
initialPayload: {
|
||||||
|
task: "Security retry path",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, "success");
|
||||||
|
assert.deepEqual(
|
||||||
|
result.records.map((record) => `${record.nodeId}:${record.status}:${String(record.attempt)}`),
|
||||||
|
["secure-node:validation_fail:1", "secure-node:success:2"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
125
tests/security-middleware.test.ts
Normal file
125
tests/security-middleware.test.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import {
|
||||||
|
SecurityRulesEngine,
|
||||||
|
SecureCommandExecutor,
|
||||||
|
SecurityViolationError,
|
||||||
|
parseShellScript,
|
||||||
|
} from "../src/security/index.js";
|
||||||
|
|
||||||
|
test("shell parser extracts Command and Word nodes across chained expressions", () => {
|
||||||
|
const parsed = parseShellScript("FOO=bar git status && npm test | cat > logs/output.txt");
|
||||||
|
|
||||||
|
assert.equal(parsed.commandCount, 3);
|
||||||
|
assert.deepEqual(
|
||||||
|
parsed.commands.map((command) => command.binary),
|
||||||
|
["git", "npm", "cat"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const gitCommand = parsed.commands[0];
|
||||||
|
assert.ok(gitCommand);
|
||||||
|
assert.equal(gitCommand.assignments[0]?.key, "FOO");
|
||||||
|
assert.deepEqual(gitCommand.args, ["status"]);
|
||||||
|
|
||||||
|
const catCommand = parsed.commands[2];
|
||||||
|
assert.ok(catCommand);
|
||||||
|
assert.deepEqual(catCommand.redirects, ["logs/output.txt"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rules engine enforces binary allowlist, tool policy, and path boundaries", async () => {
|
||||||
|
const worktreeRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-worktree-"));
|
||||||
|
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-state-"));
|
||||||
|
const projectContextPath = resolve(stateRoot, "project-context.json");
|
||||||
|
|
||||||
|
const rules = new SecurityRulesEngine({
|
||||||
|
allowedBinaries: ["git", "npm", "cat"],
|
||||||
|
worktreeRoot,
|
||||||
|
protectedPaths: [stateRoot, projectContextPath],
|
||||||
|
requireCwdWithinWorktree: true,
|
||||||
|
rejectRelativePathTraversal: true,
|
||||||
|
enforcePathBoundaryOnArguments: true,
|
||||||
|
allowedEnvAssignments: [],
|
||||||
|
blockedEnvAssignments: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowed = rules.validateShellCommand({
|
||||||
|
command: "git status && npm test | cat > logs/output.txt",
|
||||||
|
cwd: worktreeRoot,
|
||||||
|
toolClearance: {
|
||||||
|
allowlist: ["git", "npm", "cat"],
|
||||||
|
banlist: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(allowed.parsed.commandCount, 3);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
rules.validateShellCommand({
|
||||||
|
command: "cat ../secrets.txt",
|
||||||
|
cwd: worktreeRoot,
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error instanceof SecurityViolationError &&
|
||||||
|
error.code === "PATH_TRAVERSAL_BLOCKED",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
rules.validateShellCommand({
|
||||||
|
command: "git status",
|
||||||
|
cwd: stateRoot,
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error instanceof SecurityViolationError &&
|
||||||
|
error.code === "CWD_OUTSIDE_WORKTREE",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("secure executor runs with explicit env policy", async () => {
|
||||||
|
const worktreeRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-security-exec-"));
|
||||||
|
|
||||||
|
const rules = new SecurityRulesEngine({
|
||||||
|
allowedBinaries: ["echo"],
|
||||||
|
worktreeRoot,
|
||||||
|
protectedPaths: [],
|
||||||
|
requireCwdWithinWorktree: true,
|
||||||
|
rejectRelativePathTraversal: true,
|
||||||
|
enforcePathBoundaryOnArguments: true,
|
||||||
|
allowedEnvAssignments: [],
|
||||||
|
blockedEnvAssignments: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const executor = new SecureCommandExecutor({
|
||||||
|
rulesEngine: rules,
|
||||||
|
timeoutMs: 2000,
|
||||||
|
envPolicy: {
|
||||||
|
inherit: ["PATH", "HOME"],
|
||||||
|
scrub: ["SECRET_VALUE"],
|
||||||
|
inject: {
|
||||||
|
SAFE_VALUE: "ok",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let streamedStdout = "";
|
||||||
|
const result = await executor.execute({
|
||||||
|
command: "echo \"$SAFE_VALUE|$SECRET_VALUE\"",
|
||||||
|
cwd: worktreeRoot,
|
||||||
|
baseEnv: {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
HOME: process.env.HOME,
|
||||||
|
SECRET_VALUE: "hidden",
|
||||||
|
},
|
||||||
|
onStdoutChunk: (chunk) => {
|
||||||
|
streamedStdout += chunk;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.exitCode, 0);
|
||||||
|
assert.equal(result.stdout, "ok|\n");
|
||||||
|
assert.equal(streamedStdout, result.stdout);
|
||||||
|
});
|
||||||
@@ -12,5 +12,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user