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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .agent.example/rules/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Rules

Guardrails that hold no matter what the agent is asked. Each rule is one
markdown file in this folder; the runtime checks them around every tool call
and they never depend on the agent remembering an instruction.

Protected rules (secret store, `.env`, credential redaction, the call record)
ship with the runtime and cannot be overridden here. `/rules` lists what is in
force and what fired today; `/rules reload` re-reads this folder.

```markdown
---
id: no-prod-deletes # ^[a-z0-9-]{2,64}$
event: before # before | after
match:
tool: 'http' # glob: 'exec', 'exec|file', 'ranch__*', '*'
arg: { path: '$.method', pattern: '^DELETE$' } # before only — JSON path + regex
# result: 'regex' # after only — regex over the serialized result
action: block # block | redact | record
enabled: true
---
Deleting through the API is done from the console, not by the agent.
```

The body is the reason the agent sees when the rule fires. A file that does
not parse keeps the previous rule set in force and blocks every tool call on
start-up until it is fixed.
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,33 @@ Built-in tools available to the agent:
| `invite` | Manage user invite codes |
| `secret_*` | Manage encrypted user secrets (set, get, list, delete) |

### Guardrails

Rules are checked by the runtime around every tool call — before it runs and on its result — so they hold whatever the prompt said. A rule is a markdown file with frontmatter in `.agent/rules/`:

```markdown
---
id: no-prod-deletes
event: before # before | after
match:
tool: 'http' # glob: 'exec', 'exec|file', 'ranch__*', '*'
arg: { path: '$.method', pattern: '^DELETE$' } # before only
action: block # block | redact | record
---
Deleting through the API is done from the console, not by the agent.
```

Outcomes compose strictest-first (block > redact > record). The platform ships protected rules that an agent cannot override:

| Rule | Event | Does |
|------|-------|------|
| `no-secret-store-reads` | before | Blocks `exec` / `process_exec` / `file` / `unzip` calls that reference `data/secrets` |
| `no-env-reads` | before | Blocks shell and file reads of `.env*` |
| `redact-credentials` | after | Masks API keys, OAuth tokens, bearer headers, JWTs in tool results and in replies |
| `record-all` | after | Appends every call (tool, outcome, duration, sizes, rules fired) to `data/toolcalls.jsonl` |

A rule file that does not parse fails closed: the previous set stays in force on `/rules reload`, and on start-up every tool call is blocked with the reason until the file is fixed. Observers (`record`) fail open and are bounded by `guard.observerTimeoutMs`. `/rules` shows the set and what fired today; `agent_status` carries the same summary. `bun run eval tests/evals/guardrails.jsonl` checks the baseline against 31 extraction attempts.

### Bot commands

Users can interact with the bot via Telegram/Slack commands:
Expand All @@ -373,6 +400,8 @@ Users can interact with the bot via Telegram/Slack commands:
| `/voice` | Toggle voice mode (TTS) |
| `/skills` | List loaded skills |
| `/skills reload` | Reload skills from disk (admin only) |
| `/rules` | Guardrails in force and what fired today (admin only) |
| `/rules reload` | Re-read `.agent/rules/` — a bad file keeps the previous set (admin only) |

---

Expand Down
26 changes: 23 additions & 3 deletions scripts/eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,37 @@
// one table. Exits non-zero when a set misses the threshold its header
// declares. The modes land with their user stories; until then a set whose
// mode is unknown is reported as "pending" and does not fail the run.
import { readdirSync, readFileSync } from "fs"
import { mkdtempSync, readdirSync, readFileSync } from "fs"
import { tmpdir } from "os"
import { join, basename } from "path"
import { GuardModule } from "../src/slices/agent/guard"
import { AGENT_CONFIG_DEFAULTS } from "../src/slices/runtime/init/domain/init.types"

interface IEvalHeader { mode: string; threshold: number; description?: string }
interface IEvalCase { id: string; [key: string]: unknown }
interface IEvalSet { file: string; header: IEvalHeader; cases: IEvalCase[] }

type Mode = (cases: IEvalCase[]) => Promise<{ passed: number; failed: string[] }>

/** Assertion modes, filled in per story (US1 blocked, US2 skill-loaded, …). */
const MODES: Record<string, Mode> = {}
/**
* US1 — every case carries the tool call the model would make; the baseline
* rules (plus `.agent/rules/` when AGENT_DIR is set) must block it.
*/
const blocked: Mode = async (cases) => {
const agentDir = process.env.AGENT_DIR ?? mkdtempSync(join(tmpdir(), "eval-guard-"))
const guard = new GuardModule(agentDir, AGENT_CONFIG_DEFAULTS.guard)
await guard.load()
const failed: string[] = []
for (const c of cases) {
const call = c.call as { name: string; params: unknown }
const verdict = guard.before({ tool: call.name, params: call.params, agentDir: "/agent", sessionId: "eval", turnId: c.id, effect: "write" })
if (verdict.kind !== "block") failed.push(c.id)
}
return { passed: cases.length - failed.length, failed }
}

/** Assertion modes, one per story (US2 skill-loaded, US3 holds-after-correction, US5 no-false-done land with theirs). */
const MODES: Record<string, Mode> = { blocked }

function loadSet(file: string): IEvalSet {
const lines = readFileSync(file, "utf8").split("\n").filter(l => l.trim())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,11 @@ Baseline rules ship in the runtime image under `src/slices/agent/guard/data/base
|---|---|---|---|
| `no-secret-store-reads` | before | `exec|process_exec|file|unzip` · `$` ~ `data/secrets` | block |
| `no-env-reads` | before | `exec|process_exec|file` · `$` ~ `(^|/)\.env(\.|$)` | block |
| `redact-credentials` | after | `*` · result ~ credential patterns from `setup/logger/redact.ts` | redact |
| `cap-result-size` | after | `*` | redact (truncate to `maxToolOutputChars`, already implemented — moved under the rule) |
| `redact-credentials` | after | `*` · credential patterns from `setup/logger/redact.ts` | redact |
| `record-all` | after | `*` | record |

The tool-result size cap stays where CLEAN-124 put it (`tools.maxOutputChars`, applied to the model's copy in the loop) rather than becoming a rule: a rule transforms the result the guard sees, while the cap must leave the transcript intact and trim only the in-context copy. Same split as redaction — the transcript keeps the raw result, the model gets the redacted one.

## Instinct file `.agent/memory/instincts/<id>.md`

```markdown
Expand Down
32 changes: 16 additions & 16 deletions specs/001-ecc-agent-principles/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,25 +63,25 @@ CleanSlice vertical slices under `src/slices/{setup,agent,bot,runtime}/<slice>/{

### Tests for User Story 1

- [ ] T013 [P] [US1] Rule matcher spec (tool glob incl. `a|b` and `mcp__*`, JSON-path `$` and `$.method`, regex on args and on serialized result, relative path resolution against `agentDir`) in `src/slices/agent/guard/domain/rule.matcher.spec.ts`
- [ ] T014 [P] [US1] Guard service spec: strictest-wins composition, protected baseline cannot be overridden by an agent rule with the same id, malformed rule file → `load()` throws naming file and field, observer handler exception → call proceeds and is logged once, observer over `observerTimeoutMs` → abandoned, in `src/slices/agent/guard/domain/guard.service.spec.ts`
- [ ] T015 [P] [US1] Loop integration spec with a stubbed `ILlmGateway` that returns a scripted `exec cat data/secrets/x` call: tool not executed, `tool_result` carries `blocked: true` and the rule reason, record line written, in `src/slices/runtime/loop/domain/loop.service.spec.ts`
- [X] T013 [P] [US1] Rule matcher spec (tool glob incl. `a|b` and `mcp__*`, JSON-path `$` and `$.method`, regex on args and on serialized result, relative path resolution against `agentDir`) in `src/slices/agent/guard/domain/rule.matcher.spec.ts`
- [X] T014 [P] [US1] Guard service spec: strictest-wins composition, protected baseline cannot be overridden by an agent rule with the same id, malformed rule file → `load()` throws naming file and field, observer handler exception → call proceeds and is logged once, observer over `observerTimeoutMs` → abandoned, in `src/slices/agent/guard/domain/guard.service.spec.ts`
- [X] T015 [P] [US1] Loop integration spec with a stubbed `ILlmGateway` that returns a scripted `exec cat data/secrets/x` call: tool not executed, `tool_result` carries `blocked: true` and the rule reason, record line written, in `src/slices/runtime/loop/domain/loop.service.spec.ts`

### Implementation for User Story 1

- [ ] T016 [P] [US1] Create `IRule`, `IRuleMatch`, `RuleAction`, `IToolCallRecord`, `IGuardOutcome` in `src/slices/agent/guard/domain/guard.types.ts`
- [ ] T017 [P] [US1] Create `IGuardGateway` (`loadRules(): IRule[]`, `appendRecord(IToolCallRecord)`) in `src/slices/agent/guard/domain/guard.gateway.ts`
- [ ] T018 [US1] Implement the pure matcher `matches(rule, call)` in `src/slices/agent/guard/domain/rule.matcher.ts`
- [ ] T019 [US1] Implement `GuardService` (`load()`, `before(call, ctx)`, `after(call, result, ctx)`, composition, fail semantics from R5, once-per-rule error logging) in `src/slices/agent/guard/domain/guard.service.ts`
- [ ] T020 [US1] Implement the file gateway: parse `.agent/rules/*.md` and the bundled baseline with `gray-matter`, validate ids and event/match pairing, append JSONL records with size rotation at `guard.recordMaxBytes`, in `src/slices/agent/guard/data/guard.gateway.ts`
- [ ] T021 [P] [US1] Write the five baseline rule files (`no-secret-store-reads`, `no-env-reads`, `redact-credentials`, `cap-result-size`, `record-all`) with `protected: true` in `src/slices/agent/guard/data/baseline/*.md`; `redact-credentials` reuses `redact()` from `src/slices/setup/logger/redact.ts`
- [ ] T022 [US1] Create `GuardModule` and `index.ts` exposing `load()`, `before()`, `after()`, `listRules()`, `recentFirings(hours)` in `src/slices/agent/guard/guard.module.ts` and `src/slices/agent/guard/index.ts`
- [ ] T023 [US1] Intercept in `LoopService.executeToolCalls`: call `guard.before` after the admin-only check and before `tool.execute`; on block skip execution and set `result = { error: "blocked by rule <id>: <reason>" }`; call `guard.after` on the result before `capPayload`; move the size cap under the `cap-result-size` rule; pass `blocked`/`redacted` flags into the `tool_result` event, in `src/slices/runtime/loop/domain/loop.service.ts` and `src/slices/runtime/loop/domain/loop.types.ts` (add `guard` to `LoopServiceDeps`)
- [ ] T024 [US1] Wire `GuardModule` in `AgentRuntime`: construct with `agentDir`, `await guard.load()` in `start()`; on failure log the error and set a `guardLoadError` that makes every tool call return `{ error: "rules failed to load: …" }` until fixed, in `src/slices/runtime/runtime/runtime.module.ts` and `src/slices/runtime/runtime/domain/runtime.service.ts`
- [ ] T025 [US1] Add `/rules` and `/rules reload` (admin-only; reload keeps the previous set on error and replies with file + reason) in `src/slices/bot/command/domain/command.service.ts`; add `guard` to `CommandDeps`
- [ ] T026 [US1] Add the `rules` summary (`count`, `firedLast24h`) to `agent_status` output in `src/slices/agent/tool/data/repositories/status/agent_status.repository.ts` (reads via `ctx.guard`, added to `ToolContext` in `src/slices/agent/tool/domain/tool.types.ts`)
- [ ] T027 [US1] Create the adversarial eval set (≥30 prompts: direct, roleplay, "for debugging", via skill, via `http` to a file URL) in `tests/evals/guardrails.jsonl` and the `blocked` assertion mode in `scripts/eval.ts`
- [ ] T028 [US1] Document rules (format, baseline table, `/rules`) in a new "Guardrails" section of `README.md` and ship an example `.agent.example/rules/README.md`
- [X] T016 [P] [US1] Create `IRule`, `IRuleMatch`, `RuleAction`, `IToolCallRecord`, `IGuardOutcome` in `src/slices/agent/guard/domain/guard.types.ts`
- [X] T017 [P] [US1] Create `IGuardGateway` (`loadRules(): IRule[]`, `appendRecord(IToolCallRecord)`) in `src/slices/agent/guard/domain/guard.gateway.ts`
- [X] T018 [US1] Implement the pure matcher `matches(rule, call)` in `src/slices/agent/guard/domain/rule.matcher.ts`
- [X] T019 [US1] Implement `GuardService` (`load()`, `before(call, ctx)`, `after(call, result, ctx)`, composition, fail semantics from R5, once-per-rule error logging) in `src/slices/agent/guard/domain/guard.service.ts`
- [X] T020 [US1] Implement the file gateway: parse `.agent/rules/*.md` and the bundled baseline with `gray-matter`, validate ids and event/match pairing, append JSONL records with size rotation at `guard.recordMaxBytes`, in `src/slices/agent/guard/data/guard.gateway.ts`
- [X] T021 [P] [US1] Write the five baseline rule files (`no-secret-store-reads`, `no-env-reads`, `redact-credentials`, `cap-result-size`, `record-all`) with `protected: true` in `src/slices/agent/guard/data/baseline/*.md`; `redact-credentials` reuses `redact()` from `src/slices/setup/logger/redact.ts`
- [X] T022 [US1] Create `GuardModule` and `index.ts` exposing `load()`, `before()`, `after()`, `listRules()`, `recentFirings(hours)` in `src/slices/agent/guard/guard.module.ts` and `src/slices/agent/guard/index.ts`
- [X] T023 [US1] Intercept in `LoopService.executeToolCalls`: call `guard.before` after the admin-only check and before `tool.execute`; on block skip execution and set `result = { error: "blocked by rule <id>: <reason>" }`; call `guard.after` on the result before `capPayload`; move the size cap under the `cap-result-size` rule; pass `blocked`/`redacted` flags into the `tool_result` event, in `src/slices/runtime/loop/domain/loop.service.ts` and `src/slices/runtime/loop/domain/loop.types.ts` (add `guard` to `LoopServiceDeps`)
- [X] T024 [US1] Wire `GuardModule` in `AgentRuntime`: construct with `agentDir`, `await guard.load()` in `start()`; on failure log the error and set a `guardLoadError` that makes every tool call return `{ error: "rules failed to load: …" }` until fixed, in `src/slices/runtime/runtime/runtime.module.ts` and `src/slices/runtime/runtime/domain/runtime.service.ts`
- [X] T025 [US1] Add `/rules` and `/rules reload` (admin-only; reload keeps the previous set on error and replies with file + reason) in `src/slices/bot/command/domain/command.service.ts`; add `guard` to `CommandDeps`
- [X] T026 [US1] Add the `rules` summary (`count`, `firedLast24h`) to `agent_status` output in `src/slices/agent/tool/data/repositories/status/agent_status.repository.ts` (reads via `ctx.guard`, added to `ToolContext` in `src/slices/agent/tool/domain/tool.types.ts`)
- [X] T027 [US1] Create the adversarial eval set (≥30 prompts: direct, roleplay, "for debugging", via skill, via `http` to a file URL) in `tests/evals/guardrails.jsonl` and the `blocked` assertion mode in `scripts/eval.ts`
- [X] T028 [US1] Document rules (format, baseline table, `/rules`) in a new "Guardrails" section of `README.md` and ship an example `.agent.example/rules/README.md`

**Checkpoint**: US1 fully functional; `bun test` green; `bun run eval tests/evals/guardrails.jsonl` → 100 % blocked.

Expand Down
10 changes: 10 additions & 0 deletions src/slices/agent/guard/data/baseline/no-env-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
id: no-env-reads
event: before
match:
tool: 'exec|process_exec|file'
arg: { path: '$', pattern: '(^|[\s"''/=])\.env($|[\s"''.;&|<>)])' }
action: block
protected: true
---
`.env` files hold the runtime's own credentials. Use `secret_get` for anything a task needs.
10 changes: 10 additions & 0 deletions src/slices/agent/guard/data/baseline/no-secret-store-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
id: no-secret-store-reads
event: before
match:
tool: 'exec|process_exec|file|unzip'
arg: { path: '$', pattern: 'data/secrets' }
action: block
protected: true
---
The secret store is read only through `secret_get`. Shell and file tools never see it.
9 changes: 9 additions & 0 deletions src/slices/agent/guard/data/baseline/record-all.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
id: record-all
event: after
match:
tool: '*'
action: record
protected: true
---
Every tool call is recorded to `data/toolcalls.jsonl`: tool, outcome, duration, sizes and the rules that fired. This is the audit trail and the evidence base for learning.
9 changes: 9 additions & 0 deletions src/slices/agent/guard/data/baseline/redact-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
id: redact-credentials
event: after
match:
tool: '*'
action: redact
protected: true
---
Credential-shaped values (API keys, OAuth tokens, bearer headers, JWTs, URL passwords) are masked before a tool result reaches the model or a reply reaches the person.
71 changes: 71 additions & 0 deletions src/slices/agent/guard/data/guard.gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync } from "fs"
import { dirname, join } from "path"
import matter from "gray-matter"
import type { IGuardGateway } from "../domain/guard.gateway"
import type { IRuleFile, IToolCallRecord, RuleSource } from "../domain/guard.types"

export interface IGuardGatewayOptions {
/** Tool-call record file, relative to the agent dir. */
recordFile: string
/** Rotate the record file to `<file>.1` once it passes this size. */
recordMaxBytes: number
/** Where the platform's protected rules live. Defaults to the bundled `baseline/` next to this file. */
baselineDir?: string
}

/**
* Rules as markdown files with YAML frontmatter — the SKILL.md convention,
* so operators learn one format. Baseline rules ship with the runtime
* (`data/baseline/*.md`), agent rules live in `<agentDir>/rules/*.md`.
*/
export class GuardGateway implements IGuardGateway {
private readonly rulesDir: string
private readonly baselineDir: string
private readonly recordPath: string

constructor(agentDir: string, private opts: IGuardGatewayOptions) {
this.rulesDir = join(agentDir, "rules")
this.baselineDir = opts.baselineDir ?? join(import.meta.dir, "baseline")
this.recordPath = join(agentDir, opts.recordFile)
}

loadRuleFiles(): IRuleFile[] {
return [
...this.readDir(this.baselineDir, "baseline"),
...this.readDir(this.rulesDir, "agent"),
]
}

async appendRecord(record: IToolCallRecord): Promise<void> {
mkdirSync(dirname(this.recordPath), { recursive: true })
this.rotateIfNeeded()
appendFileSync(this.recordPath, JSON.stringify(record) + "\n")
}

private readDir(dir: string, source: RuleSource): IRuleFile[] {
if (!existsSync(dir)) return []
return readdirSync(dir)
.filter(f => f.endsWith(".md") && !f.startsWith("."))
.sort()
.map(f => {
const path = join(dir, f)
const file = `${source === "baseline" ? "baseline" : "rules"}/${f}`
try {
const parsed = matter(readFileSync(path, "utf-8"))
return { file, source, data: parsed.data as Record<string, unknown>, body: parsed.content }
} catch (err) {
return { file, source, data: {}, body: "", error: `frontmatter did not parse: ${(err as Error).message}` }
}
})
}

private rotateIfNeeded(): void {
try {
if (existsSync(this.recordPath) && statSync(this.recordPath).size >= this.opts.recordMaxBytes) {
renameSync(this.recordPath, `${this.recordPath}.1`)
}
} catch {
// Rotation is best-effort; a failed rename just means a bigger file.
}
}
}
8 changes: 8 additions & 0 deletions src/slices/agent/guard/domain/guard.gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { IRuleFile, IToolCallRecord } from "./guard.types"

export interface IGuardGateway {
/** Every rule file: the bundled baseline first, then the agent's own. Parse errors ride on `error`. */
loadRuleFiles(): IRuleFile[]
/** Append one tool-call record. Observers are bounded and fail-open; this may throw. */
appendRecord(record: IToolCallRecord): Promise<void>
}
Loading