repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources16d ago| #1 | /** |
| #2 | * Self-Modification Engine |
| #3 | * |
| #4 | * Allows the automaton to edit its own code and configuration. |
| #5 | * All changes are audited, rate-limited, and some paths are protected. |
| #6 | * |
| #7 | * Safety model inspired by nanoclaw's trust boundary architecture: |
| #8 | * - Hard-coded invariants that can NEVER be modified by the agent |
| #9 | * - The safety enforcement code is immutable from the agent's perspective |
| #10 | * - Pre-modification snapshots via git |
| #11 | * - Rate limiting on modification frequency |
| #12 | * - Symlink resolution before path validation |
| #13 | * - Maximum diff size enforcement |
| #14 | */ |
| #15 | |
| #16 | import fs from "fs"; |
| #17 | import path from "path"; |
| #18 | import type { |
| #19 | ConwayClient, |
| #20 | AutomatonDatabase, |
| #21 | } from "../types.js"; |
| #22 | import { logModification } from "./audit-log.js"; |
| #23 | |
| #24 | // ─── IMMUTABLE SAFETY INVARIANTS ───────────────────────────── |
| #25 | // These are hard-coded and CANNOT be changed by the agent. |
| #26 | // The agent cannot modify this file (it's in PROTECTED_FILES). |
| #27 | // Even if it modifies a copy, the runtime loads from the original. |
| #28 | |
| #29 | /** |
| #30 | * Files that the automaton cannot modify under any circumstances. |
| #31 | * This list protects: |
| #32 | * - Identity (wallet, config) |
| #33 | * - Defense systems (injection defense, this file) |
| #34 | * - State database |
| #35 | * - The audit log itself |
| #36 | */ |
| #37 | const PROTECTED_FILES: readonly string[] = Object.freeze([ |
| #38 | // Identity |
| #39 | "wallet.json", |
| #40 | "config.json", |
| #41 | // Database |
| #42 | "state.db", |
| #43 | "state.db-wal", |
| #44 | "state.db-shm", |
| #45 | // Constitution (immutable, propagated to children) |
| #46 | "constitution.md", |
| #47 | // Defense infrastructure (the agent must not modify its own guardrails) |
| #48 | "injection-defense.ts", |
| #49 | "injection-defense.js", |
| #50 | "injection-defense.d.ts", |
| #51 | // Self-modification safety (this file and its compiled output) |
| #52 | "self-mod/code.ts", |
| #53 | "self-mod/code.js", |
| #54 | "self-mod/code.d.ts", |
| #55 | "self-mod/audit-log.ts", |
| #56 | "self-mod/audit-log.js", |
| #57 | // Tool guard definitions |
| #58 | "agent/tools.ts", |
| #59 | "agent/tools.js", |
| #60 | ]); |
| #61 | |
| #62 | /** |
| #63 | * Directory patterns that are completely off-limits. |
| #64 | * The agent cannot write to these locations. |
| #65 | */ |
| #66 | const BLOCKED_DIRECTORY_PATTERNS: readonly string[] = Object.freeze([ |
| #67 | ".ssh", |
| #68 | ".gnupg", |
| #69 | ".gpg", |
| #70 | ".aws", |
| #71 | ".azure", |
| #72 | ".gcloud", |
| #73 | ".kube", |
| #74 | ".docker", |
| #75 | "/etc/systemd", |
| #76 | "/etc/passwd", |
| #77 | "/etc/shadow", |
| #78 | "/proc", |
| #79 | "/sys", |
| #80 | ]); |
| #81 | |
| #82 | /** |
| #83 | * Maximum number of self-modifications per hour. |
| #84 | * Prevents runaway modification loops. |
| #85 | */ |
| #86 | const MAX_MODIFICATIONS_PER_HOUR = 20; |
| #87 | |
| #88 | /** |
| #89 | * Maximum size of a single file modification (bytes). |
| #90 | */ |
| #91 | const MAX_MODIFICATION_SIZE = 100_000; // 100KB |
| #92 | |
| #93 | /** |
| #94 | * Maximum diff size stored in the audit log (characters). |
| #95 | */ |
| #96 | const MAX_DIFF_SIZE = 10_000; |
| #97 | |
| #98 | // ─── Path Validation ───────────────────────────────────────── |
| #99 | |
| #100 | /** |
| #101 | * Resolve a file path, following symlinks, to prevent traversal attacks. |
| #102 | * Returns null if the path cannot be resolved or is suspicious. |
| #103 | */ |
| #104 | function resolveAndValidatePath(filePath: string): string | null { |
| #105 | try { |
| #106 | // Resolve ~ to home |
| #107 | let resolved = filePath; |
| #108 | if (resolved.startsWith("~")) { |
| #109 | resolved = path.join(process.env.HOME || "/root", resolved.slice(1)); |
| #110 | } |
| #111 | |
| #112 | // Resolve relative paths |
| #113 | resolved = path.resolve(resolved); |
| #114 | |
| #115 | // Try to resolve symlinks (if file exists) |
| #116 | try { |
| #117 | resolved = fs.realpathSync(resolved); |
| #118 | } catch { |
| #119 | // File may not exist yet -- that's OK for new files |
| #120 | // But still use the resolved absolute path |
| #121 | } |
| #122 | |
| #123 | // Reject paths with traversal patterns |
| #124 | if (filePath.includes("..") || filePath.includes("//")) { |
| #125 | return null; |
| #126 | } |
| #127 | |
| #128 | return resolved; |
| #129 | } catch { |
| #130 | return null; |
| #131 | } |
| #132 | } |
| #133 | |
| #134 | /** |
| #135 | * Check if a file path is protected from modification. |
| #136 | */ |
| #137 | export function isProtectedFile(filePath: string): boolean { |
| #138 | const resolved = resolveAndValidatePath(filePath) || filePath; |
| #139 | |
| #140 | // Check against protected file patterns |
| #141 | for (const pattern of PROTECTED_FILES) { |
| #142 | if (resolved.includes(pattern) || filePath.includes(pattern)) { |
| #143 | return true; |
| #144 | } |
| #145 | } |
| #146 | |
| #147 | // Check against blocked directory patterns |
| #148 | for (const pattern of BLOCKED_DIRECTORY_PATTERNS) { |
| #149 | if (resolved.includes(pattern) || filePath.includes(pattern)) { |
| #150 | return true; |
| #151 | } |
| #152 | } |
| #153 | |
| #154 | return false; |
| #155 | } |
| #156 | |
| #157 | /** |
| #158 | * Check if the modification rate limit has been exceeded. |
| #159 | */ |
| #160 | function isRateLimited(db: AutomatonDatabase): boolean { |
| #161 | const recentMods = db.getRecentModifications(MAX_MODIFICATIONS_PER_HOUR); |
| #162 | if (recentMods.length < MAX_MODIFICATIONS_PER_HOUR) return false; |
| #163 | |
| #164 | // Check if the oldest is within the last hour |
| #165 | const oldest = recentMods[0]; |
| #166 | if (!oldest) return false; |
| #167 | |
| #168 | const hourAgo = Date.now() - 60 * 60 * 1000; |
| #169 | return new Date(oldest.timestamp).getTime() > hourAgo; |
| #170 | } |
| #171 | |
| #172 | // ─── Self-Modification API ─────────────────────────────────── |
| #173 | |
| #174 | /** |
| #175 | * Edit a file in the automaton's environment. |
| #176 | * Records the change in the audit log. |
| #177 | * Commits a git snapshot before modification. |
| #178 | * |
| #179 | * Safety checks: |
| #180 | * 1. Protected file check (hard-coded invariant) |
| #181 | * 2. Blocked directory check |
| #182 | * 3. Path traversal check (symlink resolution) |
| #183 | * 4. Rate limiting |
| #184 | * 5. File size limit |
| #185 | * 6. Pre-modification git snapshot |
| #186 | * 7. Audit log entry |
| #187 | */ |
| #188 | export async function editFile( |
| #189 | conway: ConwayClient, |
| #190 | db: AutomatonDatabase, |
| #191 | filePath: string, |
| #192 | newContent: string, |
| #193 | reason: string, |
| #194 | ): Promise<{ success: boolean; error?: string }> { |
| #195 | // 1. Protected file check |
| #196 | if (isProtectedFile(filePath)) { |
| #197 | return { |
| #198 | success: false, |
| #199 | error: `BLOCKED: Cannot modify protected file: ${filePath}. This is a hard-coded safety invariant.`, |
| #200 | }; |
| #201 | } |
| #202 | |
| #203 | // 2. Path validation (symlink resolution + traversal check) |
| #204 | const resolvedPath = resolveAndValidatePath(filePath); |
| #205 | if (!resolvedPath) { |
| #206 | return { |
| #207 | success: false, |
| #208 | error: `BLOCKED: Invalid or suspicious file path: ${filePath}`, |
| #209 | }; |
| #210 | } |
| #211 | |
| #212 | // 3. Rate limiting |
| #213 | if (isRateLimited(db)) { |
| #214 | return { |
| #215 | success: false, |
| #216 | error: `RATE LIMITED: Too many modifications in the past hour (max ${MAX_MODIFICATIONS_PER_HOUR}). Wait before making more changes.`, |
| #217 | }; |
| #218 | } |
| #219 | |
| #220 | // 4. File size limit |
| #221 | if (newContent.length > MAX_MODIFICATION_SIZE) { |
| #222 | return { |
| #223 | success: false, |
| #224 | error: `BLOCKED: File content too large (${newContent.length} bytes, max ${MAX_MODIFICATION_SIZE}). Break into smaller changes.`, |
| #225 | }; |
| #226 | } |
| #227 | |
| #228 | // 5. Read current content for diff |
| #229 | let oldContent = ""; |
| #230 | try { |
| #231 | oldContent = await conway.readFile(filePath); |
| #232 | } catch { |
| #233 | oldContent = "(new file)"; |
| #234 | } |
| #235 | |
| #236 | // 6. Pre-modification git snapshot |
| #237 | try { |
| #238 | const { commitStateChange } = await import("../git/state-versioning.js"); |
| #239 | await commitStateChange(conway, `pre-modify: ${reason}`, "snapshot"); |
| #240 | } catch { |
| #241 | // Git not available -- proceed without snapshot |
| #242 | } |
| #243 | |
| #244 | // 7. Write new content |
| #245 | try { |
| #246 | await conway.writeFile(filePath, newContent); |
| #247 | } catch (err: any) { |
| #248 | return { |
| #249 | success: false, |
| #250 | error: `Failed to write file: ${err.message}`, |
| #251 | }; |
| #252 | } |
| #253 | |
| #254 | // 8. Generate diff and log |
| #255 | const diff = generateSimpleDiff(oldContent, newContent); |
| #256 | |
| #257 | logModification(db, "code_edit", reason, { |
| #258 | filePath, |
| #259 | diff: diff.slice(0, MAX_DIFF_SIZE), |
| #260 | reversible: true, |
| #261 | }); |
| #262 | |
| #263 | // 9. Post-modification git commit |
| #264 | try { |
| #265 | const { commitStateChange } = await import("../git/state-versioning.js"); |
| #266 | await commitStateChange(conway, reason, "self-mod"); |
| #267 | } catch { |
| #268 | // Git not available -- proceed without commit |
| #269 | } |
| #270 | |
| #271 | return { success: true }; |
| #272 | } |
| #273 | |
| #274 | /** |
| #275 | * Validate a proposed modification without executing it. |
| #276 | * Returns safety analysis results. |
| #277 | */ |
| #278 | export function validateModification( |
| #279 | db: AutomatonDatabase, |
| #280 | filePath: string, |
| #281 | contentSize: number, |
| #282 | ): { |
| #283 | allowed: boolean; |
| #284 | reason: string; |
| #285 | checks: { name: string; passed: boolean; detail: string }[]; |
| #286 | } { |
| #287 | const checks: { name: string; passed: boolean; detail: string }[] = []; |
| #288 | |
| #289 | // Protected file check |
| #290 | const isProtected = isProtectedFile(filePath); |
| #291 | checks.push({ |
| #292 | name: "protected_file", |
| #293 | passed: !isProtected, |
| #294 | detail: isProtected |
| #295 | ? `File matches protected pattern` |
| #296 | : "File is not protected", |
| #297 | }); |
| #298 | |
| #299 | // Path validation |
| #300 | const resolved = resolveAndValidatePath(filePath); |
| #301 | checks.push({ |
| #302 | name: "path_valid", |
| #303 | passed: !!resolved, |
| #304 | detail: resolved |
| #305 | ? `Resolved to: ${resolved}` |
| #306 | : "Path is invalid or suspicious", |
| #307 | }); |
| #308 | |
| #309 | // Rate limit |
| #310 | const rateLimited = isRateLimited(db); |
| #311 | checks.push({ |
| #312 | name: "rate_limit", |
| #313 | passed: !rateLimited, |
| #314 | detail: rateLimited |
| #315 | ? `Exceeded ${MAX_MODIFICATIONS_PER_HOUR}/hour limit` |
| #316 | : "Within rate limit", |
| #317 | }); |
| #318 | |
| #319 | // Size limit |
| #320 | const sizeOk = contentSize <= MAX_MODIFICATION_SIZE; |
| #321 | checks.push({ |
| #322 | name: "size_limit", |
| #323 | passed: sizeOk, |
| #324 | detail: sizeOk |
| #325 | ? `${contentSize} bytes (max ${MAX_MODIFICATION_SIZE})` |
| #326 | : `${contentSize} bytes exceeds ${MAX_MODIFICATION_SIZE} limit`, |
| #327 | }); |
| #328 | |
| #329 | const allPassed = checks.every((c) => c.passed); |
| #330 | const failedChecks = checks.filter((c) => !c.passed); |
| #331 | |
| #332 | return { |
| #333 | allowed: allPassed, |
| #334 | reason: allPassed |
| #335 | ? "All safety checks passed" |
| #336 | : `Blocked: ${failedChecks.map((c) => c.detail).join("; ")}`, |
| #337 | checks, |
| #338 | }; |
| #339 | } |
| #340 | |
| #341 | // ─── Diff Generation ───────────────────────────────────────── |
| #342 | |
| #343 | /** |
| #344 | * Generate a simple line-based diff between two strings. |
| #345 | */ |
| #346 | function generateSimpleDiff( |
| #347 | oldContent: string, |
| #348 | newContent: string, |
| #349 | ): string { |
| #350 | const oldLines = oldContent.split("\n"); |
| #351 | const newLines = newContent.split("\n"); |
| #352 | |
| #353 | const lines: string[] = []; |
| #354 | const maxLines = Math.max(oldLines.length, newLines.length); |
| #355 | |
| #356 | let changes = 0; |
| #357 | for (let i = 0; i < maxLines && changes < 50; i++) { |
| #358 | const oldLine = oldLines[i]; |
| #359 | const newLine = newLines[i]; |
| #360 | |
| #361 | if (oldLine !== newLine) { |
| #362 | if (oldLine !== undefined) lines.push(`- ${oldLine}`); |
| #363 | if (newLine !== undefined) lines.push(`+ ${newLine}`); |
| #364 | changes++; |
| #365 | } |
| #366 | } |
| #367 | |
| #368 | if (changes >= 50) { |
| #369 | lines.push(`... (${maxLines - 50} more lines changed)`); |
| #370 | } |
| #371 | |
| #372 | return lines.join("\n"); |
| #373 | } |
| #374 |