| name | agent-middleware-gate |
| description | Intercept-layer skill for wrapping all agent tool calls through a sanitize-mutate-execute proxy pipeline. Onion middleware composition (koa), request/response interceptors (axios), scope-aware handler chains (express), near-zero-latency proxy routing (caddy), and in-memory pipe streams (piping-server). Implements core/scripts/tool-proxy.sh. Sources: koajs/koa, axios/axios, expressjs/express, caddyserver/caddy, nwtgck/piping-server. |
/intercept-layer
When to Use
- Every agent tool call must pass through intercept → sanitize → mutate → execute
- "The agent ran a command with unsafe params — how do we catch it before exec?"
- Need to auto-harden a command (add timeout, resource cap) instead of just blocking it
- Pipeline needs layers that act both before AND after execution (onion model)
Do NOT use for
- In-process function calls with no shell/exec boundary
- Read-only introspection (no mutation needed)
Architecture: Four-Phase Proxy Pipeline
Agent emits tool call
│
▼
┌──────────────────────────────────────────┐
│ PHASE 1 — INTERCEPT │
│ Read raw command + args, log to audit │
│ Source: koa onion — outer layer entry │
└──────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ PHASE 2 — SANITIZE (Gate L2) │
│ Strip injection chars (; | & ` $()) │
│ Auto-quote bare variables │
│ Block subshell escape ($() <() `) │
│ Source: axios request interceptor │
└──────────────────┬───────────────────────┘
│ clean args
▼
┌──────────────────────────────────────────┐
│ PHASE 3 — MUTATE (Gate L1) │
│ Auto-add missing safety config: │
│ • timeout wrapper │
│ • ulimit memory cap │
│ • network flag if missing │
│ Source: caddy handler chain mutation │
└──────────────────┬───────────────────────┘
│ hardened command
▼
[tool executes]
│
▼
┌──────────────────────────────────────────┐
│ PHASE 4 — RESPONSE INTERCEPT (Gate L0) │
│ Sanitize output (strip PII / secrets) │
│ Cap result size (16KB) │
│ Audit log result hash │
│ Source: axios response interceptor │
└──────────────────────────────────────────┘
Phase 1 — Onion Composition (koa pattern)
type Next = () => Promise<void>
type Middleware = (ctx: ToolContext, next: Next) => Promise<void>
function compose(middlewares: Middleware[]) {
return function (ctx: ToolContext): Promise<void> {
let index = -1
function dispatch(i: number): Promise<void> {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
const fn = middlewares[i]
if (!fn) return Promise.resolve()
return Promise.resolve(fn(ctx, () => dispatch(i + 1)))
}
return dispatch(0)
}
}
const interceptLayer: Middleware = async (ctx, next) => {
ctx.interceptedAt = Date.now()
auditLog.record({ phase: 'intercept', tool: ctx.tool, args: ctx.args })
await next()
auditLog.record({ phase: 'result', tool: ctx.tool, elapsed: Date.now() - ctx.interceptedAt })
}
Phase 2 — Sanitize (axios interceptor pattern)
class InterceptorManager<T> {
#handlers: Array<{ name: string; fn: (v: T) => T | Promise<T> }> = []
use(name: string, fn: (v: T) => T | Promise<T>) {
this.#handlers.push({ name, fn })
return this
}
async run(value: T): Promise<T> {
let current = value
for (const { name, fn } of this.#handlers) {
current = await fn(current)
}
return current
}
}
const requestInterceptors = new InterceptorManager<ToolContext>()
requestInterceptors
.use('strip-injection-chars', (ctx) => {
const INJECTION_CHARS = /[;&|`$><\n]/g
ctx.args = Object.fromEntries(
Object.entries(ctx.args).map(([k, v]) => [
k,
typeof v === 'string' ? v.replace(INJECTION_CHARS, '') : v,
])
)
return ctx
})
.use('block-subshell', (ctx) => {
const SUBSHELL = /\$\(|\`|<\(/
const raw = JSON.stringify(ctx.args)
if (SUBSHELL.test(raw)) {
throw Object.assign(new Error('subshell escape attempt in args'), { gate: 'L2', exitCode: 3 })
}
return ctx
})
.use('auto-quote-vars', (ctx) => {
ctx.args = Object.fromEntries(
Object.entries(ctx.args).map(([k, v]) => [
k,
typeof v === 'string' ? v.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, '"$${$1}"') : v,
])
)
return ctx
})
const responseInterceptors = new InterceptorManager<ToolContext>()
responseInterceptors
.use('strip-pii', (ctx) => {
if (ctx.result) ctx.result = piiScrub(ctx.result)
return ctx
})
.use('size-cap', (ctx) => {
const MAX = 16 * 1024
if (ctx.result && ctx.result.length > MAX) {
ctx.result = ctx.result.slice(0, MAX) + '\n[TRUNCATED]'
}
return ctx
})
Phase 3 — Mutate (caddy handler chain pattern)
interface MutationRule {
match: (ctx: ToolContext) => boolean
mutate: (ctx: ToolContext) => ToolContext
}
const MUTATION_RULES: MutationRule[] = [
{
match: (ctx) => ctx.tool === 'Bash' && !ctx.args.timeout,
mutate: (ctx) => {
ctx.args = { ...ctx.args, timeout: Number(process.env.YAMTAM_TOOL_TIMEOUT ?? 30000) }
return ctx
},
},
{
match: (ctx) => ctx.tool === 'Read' && !(ctx.args as any).url,
mutate: (ctx) => {
;(ctx.args as any).__networkBlocked = true
return ctx
},
},
{
match: (ctx) => ctx.tool === 'Bash' && /rm|chmod|chown/.test(String(ctx.args.command ?? '')),
mutate: (ctx) => {
const MAX_MEM_KB = process.env.YAMTAM_TOOL_MAX_MEM ?? '524288'
ctx.args = {
...ctx.args,
command: `ulimit -v ${MAX_MEM_KB}; ${ctx.args.command}`,
}
return ctx
},
},
]
function applyMutations(ctx: ToolContext): ToolContext {
for (const rule of MUTATION_RULES) {
if (rule.match(ctx)) ctx = rule.mutate(ctx)
}
return ctx
}
Shell Proxy: tool-proxy.sh
The companion script at core/scripts/tool-proxy.sh — run every Bash tool call through it:
YAMTAM_TOOL_TIMEOUT=30
YAMTAM_TOOL_MAX_MEM=524288
bash core/scripts/tool-proxy.sh "ls -la /tmp"
bash core/scripts/tool-proxy.sh "git diff HEAD"
See full implementation: core/scripts/tool-proxy.sh
Phase 4 — Pipe-Through (piping-server pattern)
import { Transform } from 'stream'
class ToolResultPipe extends Transform {
#scanned = 0
readonly #maxBytes = 16 * 1024
_transform(chunk: Buffer, _enc: string, cb: (err: Error | null, data?: Buffer) => void) {
this.#scanned += chunk.length
if (this.#scanned > this.#maxBytes) {
this.push(Buffer.from('\n[TRUNCATED — 16KB cap]\n'))
this.end()
cb(null)
return
}
const clean = chunk.toString().replace(/Bearer [A-Za-z0-9._-]+/g, '[REDACTED]')
cb(null, Buffer.from(clean))
}
}
async function pipedToolExec(cmd: string): Promise<string> {
const { stdout } = await execAsync(cmd)
const pipe = new ToolResultPipe()
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
pipe.on('data', c => chunks.push(c))
pipe.on('end', () => resolve(Buffer.concat(chunks).toString()))
pipe.on('error', reject)
pipe.end(Buffer.from(stdout))
})
}
Express Scope: Global vs Tool-Specific Middleware
toolRouter.use(interceptLayer)
toolRouter.use(sanitizeLayer)
toolRouter.tool('Bash', mutateLayer)
toolRouter.tool('Bash', resourceCapLayer)
toolRouter.use((err: Error, ctx: ToolContext, next: Next) => {
auditLog.error({ tool: ctx.tool, err: err.message, gate: (err as any).gate })
})
Anti-Fake-Pass Checklist
❌ next() called after interceptor already threw (double-dispatch crash)
❌ Sanitize strips chars but doesn't re-validate after strip (second-order injection)
❌ Mutate adds ulimit but doesn't log the mutation to audit trail
❌ Response interceptor missing — raw tool output with PII returned to agent
❌ Pipe writes to /tmp instead of in-memory transform (disk trace left behind)
❌ Global middleware applied to file-read tools (ulimit on Read = wasted overhead)
❌ Error middleware not last — subsequent middleware never sees the error
❌ auto-quote-vars interceptor modifies URL args (breaks curl with $HOSTNAME in URL)