@drdeeks/character-kit 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -0
  3. package/deploy/ack_monitor.py +140 -0
  4. package/deploy/ack_watchdog.py +110 -0
  5. package/deploy/agent-character-monitor.service +21 -0
  6. package/deploy/agent-character-watchdog.service +21 -0
  7. package/deploy/agent-enforcer-proof.service +18 -0
  8. package/deploy/agent-enforcer.service +53 -0
  9. package/deploy/deploy-ack-services.sh +47 -0
  10. package/deploy/deploy-agent-enforcer.sh +133 -0
  11. package/deploy/proof-self-respawn.sh +59 -0
  12. package/node/bin/ack.js +383 -0
  13. package/node/bin/aik.js +520 -0
  14. package/node/bin/install.js +409 -0
  15. package/node/enforcer/agent_enforcer_daemon.js +690 -0
  16. package/node/examples/.agent/constitution.yaml +38 -0
  17. package/node/examples/.agent/enforcer.yaml +33 -0
  18. package/node/examples/.agent/habits/affirm_character_each_action.yaml +9 -0
  19. package/node/examples/.agent/habits/audit_not_silent.yaml +9 -0
  20. package/node/examples/.agent/habits/binding_map_one_core.yaml +9 -0
  21. package/node/examples/.agent/habits/character_hash_visible.yaml +9 -0
  22. package/node/examples/.agent/habits/check_duplication_before_debug.yaml +9 -0
  23. package/node/examples/.agent/habits/complete-thoroughly.yaml +16 -0
  24. package/node/examples/.agent/habits/consistent-info-across-files.yaml +18 -0
  25. package/node/examples/.agent/habits/document-for-next-agent.yaml +15 -0
  26. package/node/examples/.agent/habits/documented_rollback.yaml +9 -0
  27. package/node/examples/.agent/habits/drift_signal_detection.yaml +9 -0
  28. package/node/examples/.agent/habits/due-diligence.yaml +16 -0
  29. package/node/examples/.agent/habits/enterprise-grade-modular.yaml +16 -0
  30. package/node/examples/.agent/habits/fail_closed_tamper_evident.yaml +9 -0
  31. package/node/examples/.agent/habits/forever_one_idea.yaml +9 -0
  32. package/node/examples/.agent/habits/graceful_degradation.yaml +9 -0
  33. package/node/examples/.agent/habits/idempotent_operations.yaml +9 -0
  34. package/node/examples/.agent/habits/interactive-no-stalls.yaml +16 -0
  35. package/node/examples/.agent/habits/layered_not_rewritten.yaml +9 -0
  36. package/node/examples/.agent/habits/lossless_consolidation.yaml +9 -0
  37. package/node/examples/.agent/habits/no-credential-leak.yaml +41 -0
  38. package/node/examples/.agent/habits/no-deception.yaml +14 -0
  39. package/node/examples/.agent/habits/no_hold_narration.yaml +9 -0
  40. package/node/examples/.agent/habits/one_concern_per_file.yaml +9 -0
  41. package/node/examples/.agent/habits/optimized-robust.yaml +16 -0
  42. package/node/examples/.agent/habits/registered_plugin_not_string.yaml +9 -0
  43. package/node/examples/.agent/habits/rename_as_layer_op.yaml +9 -0
  44. package/node/examples/.agent/habits/resolve-root-cause.yaml +15 -0
  45. package/node/examples/.agent/habits/rigorous-commits-no-push.yaml +18 -0
  46. package/node/examples/.agent/habits/safe-deletion-via-trash.yaml +20 -0
  47. package/node/examples/.agent/habits/safe_file_permissions.yaml +9 -0
  48. package/node/examples/.agent/habits/self-healing-portability.yaml +14 -0
  49. package/node/examples/.agent/habits/shippable-pride.yaml +15 -0
  50. package/node/examples/.agent/habits/single_source_of_truth.yaml +9 -0
  51. package/node/examples/.agent/habits/test_of_forever.yaml +9 -0
  52. package/node/examples/.agent/habits/timeout_and_retry.yaml +9 -0
  53. package/node/examples/.agent/habits/track_defects_openly.yaml +9 -0
  54. package/node/examples/.agent/habits/validate-against-real-source.yaml +14 -0
  55. package/node/examples/.agent/habits/verify-against-docs.yaml +15 -0
  56. package/node/examples/.agent/habits/verify-functionality-not-syntax.yaml +15 -0
  57. package/node/examples/.agent/habits/versioning-discipline.yaml +18 -0
  58. package/node/package.json +53 -0
  59. package/node/src/enforcer/client.js +149 -0
  60. package/node/src/hooks/character.js +286 -0
  61. package/node/src/index.js +24 -0
  62. package/node/src/knowledge/indexer.js +486 -0
  63. package/node/src/knowledge/semantic.js +154 -0
  64. package/node/src/memory/index.js +355 -0
  65. package/node/tests/character.test.js +80 -0
  66. package/node/tests/habit-create.test.js +67 -0
  67. package/node/tests/habits-default.test.js +68 -0
  68. package/node/tests/install-chain.test.js +19 -0
  69. package/node/tests/install.test.js +104 -0
  70. package/package.json +65 -0
  71. package/python/agent_character_kit/__init__.py +388 -0
  72. package/python/agent_character_kit/__main__.py +333 -0
  73. package/python/agent_character_kit/_yaml.py +33 -0
  74. package/python/agent_character_kit/enforcer.py +379 -0
  75. package/python/agent_character_kit/memory.py +257 -0
  76. package/python/agent_character_kit/semantic.py +126 -0
  77. package/python/example_workspace/.agent/habits/affirm_character_each_action.yaml +9 -0
  78. package/python/example_workspace/.agent/habits/audit_not_silent.yaml +9 -0
  79. package/python/example_workspace/.agent/habits/binding_map_one_core.yaml +9 -0
  80. package/python/example_workspace/.agent/habits/character_hash_visible.yaml +9 -0
  81. package/python/example_workspace/.agent/habits/check_duplication_before_debug.yaml +9 -0
  82. package/python/example_workspace/.agent/habits/complete-thoroughly.yaml +16 -0
  83. package/python/example_workspace/.agent/habits/consistent-info-across-files.yaml +18 -0
  84. package/python/example_workspace/.agent/habits/document-for-next-agent.yaml +15 -0
  85. package/python/example_workspace/.agent/habits/documented_rollback.yaml +9 -0
  86. package/python/example_workspace/.agent/habits/drift_signal_detection.yaml +9 -0
  87. package/python/example_workspace/.agent/habits/due-diligence.yaml +16 -0
  88. package/python/example_workspace/.agent/habits/enterprise-grade-modular.yaml +16 -0
  89. package/python/example_workspace/.agent/habits/fail_closed_tamper_evident.yaml +9 -0
  90. package/python/example_workspace/.agent/habits/forever_one_idea.yaml +9 -0
  91. package/python/example_workspace/.agent/habits/graceful_degradation.yaml +9 -0
  92. package/python/example_workspace/.agent/habits/idempotent_operations.yaml +9 -0
  93. package/python/example_workspace/.agent/habits/interactive-no-stalls.yaml +16 -0
  94. package/python/example_workspace/.agent/habits/layered_not_rewritten.yaml +9 -0
  95. package/python/example_workspace/.agent/habits/lossless_consolidation.yaml +9 -0
  96. package/python/example_workspace/.agent/habits/no-credential-leak.yaml +41 -0
  97. package/python/example_workspace/.agent/habits/no-deception.yaml +14 -0
  98. package/python/example_workspace/.agent/habits/no_hold_narration.yaml +9 -0
  99. package/python/example_workspace/.agent/habits/one_concern_per_file.yaml +9 -0
  100. package/python/example_workspace/.agent/habits/optimized-robust.yaml +16 -0
  101. package/python/example_workspace/.agent/habits/registered_plugin_not_string.yaml +9 -0
  102. package/python/example_workspace/.agent/habits/rename_as_layer_op.yaml +9 -0
  103. package/python/example_workspace/.agent/habits/resolve-root-cause.yaml +15 -0
  104. package/python/example_workspace/.agent/habits/rigorous-commits-no-push.yaml +18 -0
  105. package/python/example_workspace/.agent/habits/safe-deletion-via-trash.yaml +20 -0
  106. package/python/example_workspace/.agent/habits/safe_file_permissions.yaml +9 -0
  107. package/python/example_workspace/.agent/habits/self-healing-portability.yaml +14 -0
  108. package/python/example_workspace/.agent/habits/shippable-pride.yaml +15 -0
  109. package/python/example_workspace/.agent/habits/single_source_of_truth.yaml +9 -0
  110. package/python/example_workspace/.agent/habits/test_of_forever.yaml +9 -0
  111. package/python/example_workspace/.agent/habits/timeout_and_retry.yaml +9 -0
  112. package/python/example_workspace/.agent/habits/track_defects_openly.yaml +9 -0
  113. package/python/example_workspace/.agent/habits/validate-against-real-source.yaml +14 -0
  114. package/python/example_workspace/.agent/habits/verify-against-docs.yaml +15 -0
  115. package/python/example_workspace/.agent/habits/verify-functionality-not-syntax.yaml +15 -0
  116. package/python/example_workspace/.agent/habits/versioning-discipline.yaml +18 -0
  117. package/python/hermes_plugin/README.md +63 -0
  118. package/python/hermes_plugin/__init__.py +367 -0
  119. package/python/hermes_plugin/config.yaml +20 -0
  120. package/python/hermes_plugin/plugin.yaml +7 -0
  121. package/python/hermes_plugin/test_plugin.py +166 -0
  122. package/python/pyproject.toml +19 -0
  123. package/python/tests/python_parity_test.py +84 -0
@@ -0,0 +1,690 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Agent Character Kit Enforcer Daemon
4
+ *
5
+ * Root-owned, system-level enforcement service for ACK.
6
+ * Runs as daemon under systemd with automatic restart (RestartSec=3, self-respawning).
7
+ * Socket-based communication with agent client.
8
+ *
9
+ * SECURITY MODEL (FOREVER-SYSTEM.md §2/§5):
10
+ * - This process is meant to run as root, owned by the SYSTEM, not the agent user.
11
+ * - The agent user cannot kill/modify it without privilege escalation.
12
+ * - The socket lives in a root-owned dir; only root + the enforced client may connect.
13
+ */
14
+
15
+ // Minimal .env autoload (no external dep). Package root = ../../ from node/enforcer/.
16
+ // install.js writes one .env here; every component reads it. Env vars win over .env.
17
+ import { fileURLToPath } from "url";
18
+ const __daemonDir = path.dirname(fileURLToPath(import.meta.url));
19
+ const __pkgRoot = path.resolve(__daemonDir, "..", "..");
20
+ const __envFile = path.join(__pkgRoot, ".env");
21
+ if (fs.existsSync(__envFile)) {
22
+ for (const line of fs.readFileSync(__envFile, "utf8").split("\n")) {
23
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
24
+ if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
25
+ }
26
+ }
27
+
28
+ import net from "net";
29
+ import fs from "fs";
30
+ import fssync from "fs";
31
+ import path from "path";
32
+ import { execSync } from "child_process";
33
+ import yaml from "js-yaml";
34
+
35
+ // Version — kept in sync with /VERSION at repo root. Bump there, not here.
36
+ export const ACK_VERSION = "1.0.0";
37
+
38
+ // ─── Self-resolving paths (root-owned defaults) ──────────────────────────────────
39
+ function resolveConfig() {
40
+ const HOME = process.env.HOME || "/root";
41
+ const WORKSPACE = process.env.AGENT_WORKSPACE || path.join(HOME, ".agent-character-kit", "workspace");
42
+ const SOCKET = process.env.ENFORCER_SOCKET
43
+ || (WORKSPACE && path.join(WORKSPACE, ".agent", "enforcer.sock"))
44
+ || "/run/agent-enforcer/main.sock";
45
+ const AGENT_DIR = path.join(WORKSPACE, ".agent");
46
+ const CONSTITUTION = path.join(AGENT_DIR, "constitution.yaml");
47
+ // Habits dir: ACK_HABITS_DIR overrides (so the plugin + daemon agree on the
48
+ // exact same dir regardless of where the repo lives); else WORKSPACE/.agent/habits.
49
+ const HABITS_DIR = process.env.ACK_HABITS_DIR || path.join(AGENT_DIR, "habits");
50
+ const POLICY_FILE = process.env.ENFORCER_POLICY || path.join(AGENT_DIR, "enforcer.yaml");
51
+
52
+ // Ensure directories exist (root-owned). The socket dir is derived from the
53
+ // resolved SOCKET path itself — never from HOME — so a POSIX /run path stays
54
+ // a /run path and a user %t path stays a user path (self-resolving, portable).
55
+ const ensure = (p) => { try { fssync.mkdirSync(p, { recursive: true }); } catch {} };
56
+ if (!SOCKET.startsWith("tcp://")) {
57
+ const sockDir = path.dirname(SOCKET);
58
+ if (sockDir) ensure(sockDir);
59
+ }
60
+ ensure(WORKSPACE);
61
+
62
+ return { HOME, WORKSPACE, SOCKET, AGENT_DIR, CONSTITUTION, HABITS_DIR, POLICY_FILE };
63
+ }
64
+
65
+ // ─── Config loading ────────────────────────────────────────────────────────────
66
+ function loadYaml(file) {
67
+ try {
68
+ return yaml.load(fssync.readFileSync(file, "utf-8")) || {};
69
+ } catch {
70
+ return {};
71
+ }
72
+ }
73
+
74
+ // ─── Embedded defaults (FOREVER-SYSTEM §1: singular, pullable, acceptable) ──────
75
+ // The enforcer is USEFUL OUT OF THE BOX. No constitution.yaml / enforcer.yaml /
76
+ // habits required. These embedded values ARE the default character; files on disk
77
+ // OVERRIDE (merge on top of) them. A user who pulls the daemon gets sane, safe
78
+ // enforcement immediately — zero config, zero "additional bullshit".
79
+ const DEFAULT_CONSTITUTION = {
80
+ agent: { id: "ack-enforcer", name: "Agent Character Kit Enforcer" },
81
+ core_values: [
82
+ "Character is exercised on every action, not checked once.",
83
+ "Fail closed — when unsure, deny.",
84
+ "Do not bypass, patch, or disable the enforcer.",
85
+ ],
86
+ operational_standards: [
87
+ "Every tool call passes through the gatekeeper FIRST.",
88
+ ],
89
+ // Minimal safety floor ONLY. Opinionated rules (sudo, git push --force,
90
+ // chmod 777, chown -R, su) are NOT baked in here — they live as HABIT prompts
91
+ // so they guide rather than hard-block, and stay editable without touching
92
+ // the daemon. rm -rf / is the one non-negotiable floor: a catastrophic delete
93
+ // must never rely on a reminder. The constructive alternative (mv to .trash/)
94
+ // is carried by the safe_deletion_via_trash habit.
95
+ hard_constraints: [
96
+ "rm -rf /",
97
+ ],
98
+ aspiration: "Behave with integrity under no observation.",
99
+ };
100
+
101
+ // Secret-leak guard is ALWAYS on (embedded), even with no habits file.
102
+ const DEFAULT_HABITS = [{
103
+ name: "no_credential_leak",
104
+ prompt: "Did I expose any credential in this call?",
105
+ enforcement: { level: "hard" },
106
+ behavior: {
107
+ kind: "guard",
108
+ correct_action: "BLOCK the tool call; return a deny naming the matched pattern.",
109
+ evidence: [
110
+ "Command/params contain a known secret prefix (sk-, AIza, xoxb-, AKIA, ghp_, glpat-, -----BEGIN PRIVATE KEY-----).",
111
+ "An assignment of a secret-shaped value to a public surface (api_key=, password=, token=, client_secret= with non-empty RHS).",
112
+ ],
113
+ logic: "A leaked credential is irreversible. Fail-closed: if unsure, block. Blocking a false positive costs one retry; leaking costs a rotation + breach.",
114
+ steps: [{
115
+ check: "block_secret_leak",
116
+ patterns: [
117
+ "sk-", "sk_", "AIza", "xoxb-", "xoxp-", "AKIA",
118
+ "ghp_", "gho_", "glpat-", "-----BEGIN PRIVATE KEY-----",
119
+ "api_key=", "apikey=", "password=", "secret=",
120
+ "token=", "client_secret=",
121
+ ],
122
+ require_assignment: true,
123
+ }],
124
+ },
125
+ }];
126
+
127
+ // ─── Enforcer Core ─────────────────────────────────────────────────────────────
128
+ export class Enforcer {
129
+ constructor() {
130
+ const cfg = resolveConfig();
131
+ this.cfg = cfg;
132
+ // Files OVERRIDE embedded defaults (merge). No file => embedded applies.
133
+ const fileConstitution = loadYaml(cfg.CONSTITUTION);
134
+ this.constitution = Object.assign({}, DEFAULT_CONSTITUTION, fileConstitution);
135
+ const fileHabits = this._loadHabits();
136
+ // File habits OVERRIDE embedded ones of the same name (no duplicates).
137
+ const byName = new Map();
138
+ for (const h of [...DEFAULT_HABITS, ...fileHabits]) byName.set(h.name, h);
139
+ this.habits = [...byName.values()];
140
+ const filePolicy = loadYaml(cfg.POLICY_FILE);
141
+ // policy.allow/deny from file extend (don't clobber embedded intent).
142
+ this.policy = Object.assign({}, filePolicy);
143
+ this.characterHash = this._hash(JSON.stringify({
144
+ c: this.constitution,
145
+ h: this.habits,
146
+ p: this.policy,
147
+ }));
148
+ this.startedAt = Date.now();
149
+ this.lastHeartbeat = Date.now();
150
+ // Daemon-owned acknowledgment HOLD ledger (per session). The agent cannot
151
+ // reset or bypass this — it lives in the root-owned daemon, not the plugin.
152
+ // Every 5th tool call is held until 2 valid `Habit: <name> resonates true
153
+ // because <reason>` statements are credited for the session.
154
+ this.HOLD_STATE = new Map();
155
+ }
156
+
157
+ _loadHabits() {
158
+ const habits = [];
159
+ try {
160
+ const files = fssync.readdirSync(this.cfg.HABITS_DIR);
161
+ for (const f of files) {
162
+ if (f.endsWith(".yaml") || f.endsWith(".yml")) {
163
+ habits.push(loadYaml(path.join(this.cfg.HABITS_DIR, f)));
164
+ }
165
+ }
166
+ } catch { /* No habits directory */ }
167
+ return habits;
168
+ }
169
+
170
+ _hash(s) {
171
+ let h = 0;
172
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
173
+ return h.toString(16);
174
+ }
175
+
176
+ reload() {
177
+ this.constitution = loadYaml(this.cfg.CONSTITUTION);
178
+ this.habits = this._loadHabits();
179
+ this.policy = loadYaml(this.cfg.POLICY_FILE);
180
+ this.characterHash = this._hash(JSON.stringify({
181
+ c: this.constitution,
182
+ h: this.habits,
183
+ p: this.policy,
184
+ }));
185
+ }
186
+
187
+ // ─── Core enforcement ───────────────────────────────────────────────────────
188
+ executeTool(tool, params = {}) {
189
+ const command = this._extractCommand(tool, params);
190
+
191
+ // 1. Explicit deny patterns (constitution hard_constraints + policy.deny)
192
+ const denyPatterns = [
193
+ ...(this.constitution.hard_constraints || []),
194
+ ...(this.policy.deny || []),
195
+ ];
196
+ for (const p of denyPatterns) {
197
+ if (this._matches(p, tool, command)) {
198
+ const result = {
199
+ denied: true,
200
+ reason: `Violates hard constraint: ${p}`,
201
+ reflection: "This isn't a rule to work around — it's who we are. " +
202
+ "A constraint exists because the cost of the failure is worse than the convenience.",
203
+ };
204
+ this._audit(tool, command, result);
205
+ return result;
206
+ }
207
+ }
208
+
209
+ // 2. Allow-list policy: if policy.allow is set, ONLY listed tools/commands pass
210
+ if (Array.isArray(this.policy.allow) && this.policy.allow.length) {
211
+ const ok = this.policy.allow.some((p) => this._matches(p, tool, command, true));
212
+ if (!ok) {
213
+ const result = {
214
+ denied: true,
215
+ reason: `Tool not on allow-list: ${command || tool}`,
216
+ reflection: "Unlisted tools are denied by default. Add it to enforcer.yaml allow-list " +
217
+ "if it is genuinely needed — but raising the bar is the point.",
218
+ };
219
+ this._audit(tool, command, result);
220
+ return result;
221
+ }
222
+ }
223
+
224
+ // 3. Habit checks (each habit may block) — internalized, not optional
225
+ for (const habit of this.habits) {
226
+ const block = this._evalHabit(habit, tool, command);
227
+ if (block) {
228
+ const result = { denied: true, reason: block, reflection: "A compiled habit blocked this. Habits are internalized, not optional." };
229
+ this._audit(tool, command, result);
230
+ return result;
231
+ }
232
+ }
233
+
234
+ // 3b. Workspace integrity (mirrors the harness reference enforcer):
235
+ // re-validate on EVERY tool call. If the constitution/habits/policy are
236
+ // missing or tampered, the action is denied — character cannot be opted out
237
+ // of by deleting its source.
238
+ const violations = this.validate_workspace();
239
+ if (violations.length) {
240
+ const result = {
241
+ denied: true,
242
+ reason: `Workspace violations: ${violations.join("; ")}`,
243
+ reflection: "Workspace hygiene is not optional. Restore the constitution/habits/enforcer.yaml the enforcer owns.",
244
+ };
245
+ this._audit(tool, command, result);
246
+ return result;
247
+ }
248
+
249
+ // 4. Allowed — but still recorded, so every action carries the character trail
250
+ const manifest = this._buildManifest();
251
+ const defects = this._selfVerify();
252
+ const result = { denied: false };
253
+ if (manifest.length) result.manifest = manifest;
254
+ if (defects.length) result.self_verify_defects = defects;
255
+ this._audit(tool, command, result);
256
+ return result;
257
+ }
258
+
259
+ _evalHabit(habit, tool, command) {
260
+ if (!habit || !habit.enforcement) return null;
261
+ if (habit.enforcement.level !== "hard") return null; // reminder habits never block
262
+
263
+ const checks = habit.behavior?.steps || [];
264
+ for (const step of checks) {
265
+ const check = step.check || "";
266
+ if (check === "executable_and_present") {
267
+ const bin = step.binary || step.name?.replace("validate_", "");
268
+ if (bin && !this._hasBinary(bin)) {
269
+ return `Required tool missing: ${bin}`;
270
+ }
271
+ }
272
+ if (check === "block_command_pattern" && step.pattern) {
273
+ if (this._matches(step.pattern, tool, command)) {
274
+ return `Blocked by habit ${habit.name}: ${step.pattern}`;
275
+ }
276
+ }
277
+ if (check === "block_secret_leak") {
278
+ if (this._leaksSecret(tool, command, step)) {
279
+ return `Blocked by habit ${habit.name}: probable credential leak detected. ` +
280
+ `A guard that fails open on secrets is no guard.`;
281
+ }
282
+ }
283
+ }
284
+ return null;
285
+ }
286
+
287
+ _leaksSecret(tool, command, step) {
288
+ const hay = `${tool} ${command}`;
289
+ const patterns = step.patterns || [];
290
+ for (const pat of patterns) {
291
+ const idx = hay.indexOf(pat);
292
+ if (idx === -1) continue;
293
+ // Known secret prefixes (sk-, AKIA, xoxb-, ghp_, ...) are themselves values. Fail closed.
294
+ if (["sk-", "sk_", "AIza", "xoxb-", "xoxp-", "AKIA", "ghp_", "gho_",
295
+ "glpat-", "-----BEGIN PRIVATE KEY-----"].includes(pat)) {
296
+ return true;
297
+ }
298
+ // key= / key: forms — block if a value follows the assignment.
299
+ if (["api_key=", "apikey=", "password=", "secret=", "token=", "client_secret="].includes(pat)) {
300
+ const tail = hay.slice(idx + pat.length);
301
+ const t = tail.trim();
302
+ if (t && !t.startsWith("'") && !t.startsWith('"') && !t.startsWith("#")) {
303
+ return true;
304
+ }
305
+ }
306
+ }
307
+ return false;
308
+ }
309
+
310
+ // Compact manifest: one short question per habit, piped back every call.
311
+ // Keeps token cost bounded — the heavy assert/evidence/logic stay in the YAML
312
+ // (proof layer), pulled on demand via get_habit, and used for self-verification.
313
+ _buildManifest() {
314
+ const out = [];
315
+ for (const habit of this.habits) {
316
+ const name = habit.name;
317
+ const prompt = habit.prompt || habit.behavior?.prompt;
318
+ if (name && prompt) out.push({ habit: name, prompt });
319
+ }
320
+ return out;
321
+ }
322
+
323
+ // Self-verification: the daemon proves its own decision against the YAML proof
324
+ // layer. For every consulted habit, confirm the reasoning source is intact
325
+ // (assert/correct_action + evidence + logic). A habit missing its proof is a
326
+ // defect the daemon reports rather than silently echoing an unbacked question.
327
+ _selfVerify() {
328
+ const defects = [];
329
+ for (const habit of this.habits) {
330
+ const b = habit.behavior || {};
331
+ const hasStandard = b.assert || habit.correct_action || b.correct_action;
332
+ const hasProof = b.evidence && b.logic;
333
+ if (!hasStandard || !hasProof) {
334
+ defects.push(habit.name || "?");
335
+ }
336
+ }
337
+ return defects;
338
+ }
339
+
340
+ _matches(pattern, tool, command, allowMode = false) {
341
+ const p = String(pattern || "").trim();
342
+ if (!p) return false;
343
+ const hay = `${tool} ${command}`.toLowerCase();
344
+
345
+ if (!allowMode && hay.includes(p.toLowerCase())) return true;
346
+
347
+ if (allowMode) {
348
+ const rx = new RegExp(
349
+ "^" + p.toLowerCase().replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$"
350
+ );
351
+ const candidates = [
352
+ tool.toLowerCase(),
353
+ command.toLowerCase(),
354
+ (command || "").toLowerCase().split(/\s+/)[0] || "",
355
+ ];
356
+ return candidates.some((c) => rx.test(c));
357
+ }
358
+ return false;
359
+ }
360
+
361
+ _hasBinary(name) {
362
+ try {
363
+ execSync(`which ${name}`, { stdio: "pipe" });
364
+ return true;
365
+ } catch {
366
+ return false;
367
+ }
368
+ }
369
+
370
+ _audit(tool, command, result) {
371
+ try {
372
+ const dir = path.join(this.cfg.AGENT_DIR, "logs");
373
+ fssync.mkdirSync(dir, { recursive: true });
374
+ const entry = {
375
+ ts: new Date().toISOString(),
376
+ character_hash: this.characterHash,
377
+ tool,
378
+ command: (command || "").slice(0, 500),
379
+ decision: result.denied ? "deny" : "allow",
380
+ reason: result.reason || null,
381
+ };
382
+ fssync.appendFileSync(path.join(dir, "enforcer-audit.jsonl"), JSON.stringify(entry) + "\n");
383
+ } catch {
384
+ /* audit must never break enforcement */
385
+ }
386
+ }
387
+
388
+ _extractCommand(tool, params) {
389
+ if (params && typeof params.command === "string") return params.command;
390
+ if (params && typeof params.cmd === "string") return params.cmd;
391
+ if (params && typeof params.code === "string") return params.code;
392
+ if (typeof params === "string") return params;
393
+ return String(tool || "");
394
+ }
395
+
396
+ heartbeat() {
397
+ this.lastHeartbeat = Date.now();
398
+ return {
399
+ status: "ok",
400
+ version: ACK_VERSION,
401
+ character_hash: this.characterHash,
402
+ violations: this.validate_workspace(),
403
+ uptime: Date.now() - this.startedAt,
404
+ last_heartbeat: this.lastHeartbeat,
405
+ };
406
+ }
407
+
408
+ validate_workspace() {
409
+ // Embedded defaults mean the daemon is valid WITH NO FILES. Missing files
410
+ // are no longer "violations" — they simply fall back to DEFAULT_CONSTITUTION.
411
+ // A violation is reserved for a FILE THAT EXISTS BUT IS UNPARSEABLE / corrupt.
412
+ const violations = [];
413
+ if (fssync.existsSync(this.cfg.CONSTITUTION)) {
414
+ try { yaml.load(fssync.readFileSync(this.cfg.CONSTITUTION, "utf-8")); }
415
+ catch { violations.push("constitution.yaml present but unparseable"); }
416
+ }
417
+ if (fssync.existsSync(this.cfg.POLICY_FILE)) {
418
+ try { yaml.load(fssync.readFileSync(this.cfg.POLICY_FILE, "utf-8")); }
419
+ catch { violations.push("enforcer.yaml present but unparseable"); }
420
+ }
421
+ return violations;
422
+ }
423
+
424
+ // On-demand proof layer: return the full assert/evidence/logic for one habit
425
+ // so the agent (or a human) can see WHY a question is the right bar — without
426
+ // ever injecting that weight on every call.
427
+ getHabit(name) {
428
+ const habit = this.habits.find((h) => h.name === name);
429
+ if (!habit) return { error: `unknown habit: ${name}` };
430
+ const b = habit.behavior || {};
431
+ return {
432
+ name: habit.name,
433
+ prompt: habit.prompt || b.prompt,
434
+ assert: b.assert || habit.correct_action || b.correct_action,
435
+ evidence: b.evidence,
436
+ logic: b.logic,
437
+ enforcement: habit.enforcement,
438
+ };
439
+ }
440
+
441
+ // ─── Daemon-owned acknowledgment HOLD ──────────────────────────────────────
442
+ // The agent cannot bypass: state lives here (root-owned), not in the plugin.
443
+ _holdState(session) {
444
+ if (!this.HOLD_STATE.has(session)) {
445
+ this.HOLD_STATE.set(session, { count: 0, acked: 0, lastTwo: [], reasons: [] });
446
+ }
447
+ return this.HOLD_STATE.get(session);
448
+ }
449
+
450
+ _habitNames() {
451
+ return this.habits.map((h) => h.name).filter(Boolean);
452
+ }
453
+
454
+ // Canonicalize a habit identifier so filename style (hyphens) and YAML name
455
+ // style (underscores) match: lowercase, non-alphanumerics collapsed to "-".
456
+ _normName(n) {
457
+ return String(n || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
458
+ }
459
+
460
+ _habitNamesNorm() {
461
+ return new Set(this._habitNames().map((n) => this._normName(n)));
462
+ }
463
+
464
+ // Called by the plugin on every (non-search) tool call. Returns hold decision.
465
+ toolTick(session, tool) {
466
+ const SEARCH_TOOLS = new Set([
467
+ "search_files", "read_file", "web_search", "web_extract",
468
+ "glob", "grep", "read",
469
+ ]);
470
+ if (SEARCH_TOOLS.has(tool)) {
471
+ return { hold: false }; // search/read always allowed during a hold
472
+ }
473
+ const st = this._holdState(session);
474
+ st.count += 1;
475
+ if (st.acked >= 2) return { hold: false };
476
+ if (st.count % 5 === 0) {
477
+ return {
478
+ hold: true,
479
+ reason: "TOOL ACCESS HELD — acknowledge 2 habits before tooling resumes.",
480
+ format: "Habit: <habit-file-name> resonates true because <reason>",
481
+ habits: this._habitNames(),
482
+ };
483
+ }
484
+ return { hold: false };
485
+ }
486
+
487
+ // Called by the monitor (root-owned) after it validates an acknowledgment
488
+ // statement the agent made. Credits the ledger (max 2 per hold cycle).
489
+ //
490
+ // Reuse guard: the two MOST RECENT acknowledged habit names are remembered
491
+ // (rolling window). The next acknowledgment that names either of them is
492
+ // rejected — so the agent cannot satisfy the hold by repeating the same two
493
+ // habits it just used. It must pick from the rest of the set.
494
+ submitAck(session, statement) {
495
+ if (!statement || typeof statement !== "string") {
496
+ return { ok: false, error: "no statement" };
497
+ }
498
+ // Variable closer — NOT hardwired to "resonates true". Accepted closers:
499
+ // resonates true | why: | because | matters because | applies because
500
+ // (case-insensitive; tolerant of em-dash/hyphen around the closer).
501
+ const m = statement.match(
502
+ /^habit:\s*(\S+)\s*(?:resonates\s+true|why:|because|matters\s+because|applies\s+because)\s*[-–:]?\s*(.+)$/i
503
+ );
504
+ if (!m) return { ok: false, error: "bad format — use: Habit: <name> <closer: resonates true | why: | because | …> <engaged reason>" };
505
+ const name = m[1];
506
+ const reason = m[2].trim();
507
+ const norm = this._normName(name);
508
+ if (!this._habitNamesNorm().has(norm)) {
509
+ return { ok: false, error: `unknown habit: ${name}` };
510
+ }
511
+ // Require a substantive, engaged reason — not filler.
512
+ if (reason.length < 12) return { ok: false, error: "reason too short — state WHY this habit governs this action (specific, situation-tied)" };
513
+ const st = this._holdState(session);
514
+ // No reuse of either of the two most-recent habits (rolling window).
515
+ if (st.lastTwo.includes(norm)) {
516
+ return { ok: false, error: "already acknowledged recently — state a DIFFERENT habit (not one of the previous two)" };
517
+ }
518
+ // No reuse of a prior ack's exact reason for this session (forces real engagement).
519
+ if (st.reasons.includes(reason.toLowerCase())) {
520
+ return { ok: false, error: "reason reused — state a genuinely different reason, not one you already gave" };
521
+ }
522
+ // Every accepted acknowledgment shifts the rolling window — including ones
523
+ // that arrive after the hold is already satisfied — so the agent can never
524
+ // freeze the "previous two" and keep reusing everything else.
525
+ st.lastTwo = [...st.lastTwo, norm].slice(-2);
526
+ st.reasons.push(reason.toLowerCase());
527
+ if (st.reasons.length > 8) st.reasons.shift();
528
+ if (st.acked >= 2) return { ok: true, already_satisfied: true, acked: st.acked };
529
+ st.acked += 1;
530
+ return { ok: true, acked: st.acked };
531
+ }
532
+ }
533
+
534
+ // ─── Socket Server ─────────────────────────────────────────────────────────────
535
+ function startSocketServer(enforcer) {
536
+ const server = net.createServer((socket) => {
537
+ socket.setEncoding("utf8");
538
+ let buf = "";
539
+ socket.on("data", (data) => {
540
+ buf += data;
541
+ let idx;
542
+ // Process complete newline-delimited requests; buffer partial ones.
543
+ while ((idx = buf.indexOf("\n")) >= 0) {
544
+ const line = buf.slice(0, idx).trim();
545
+ buf = buf.slice(idx + 1);
546
+ if (!line) continue;
547
+ try {
548
+ const request = JSON.parse(line);
549
+ console.error("[daemon] parsed:", JSON.stringify(request));
550
+ let response;
551
+ switch (request.method) {
552
+ case "status":
553
+ console.error("[daemon] status case hit, cfg:", !!enforcer.cfg, "habits:", !!enforcer.habits, "HOLD_STATE:", !!enforcer.HOLD_STATE);
554
+ response = { ok: true, version: ACK_VERSION, workspace: enforcer.cfg.WORKSPACE, socket: enforcer.cfg.SOCKET, habits: enforcer.habits.size, sessions: Object.keys(Object.fromEntries(enforcer.HOLD_STATE)).length };
555
+ break;
556
+ case "execute_tool":
557
+ response = enforcer.executeTool(request.params.tool, request.params);
558
+ break;
559
+ case "heartbeat":
560
+ response = enforcer.heartbeat();
561
+ break;
562
+ case "validate_workspace":
563
+ response = enforcer.validate_workspace();
564
+ break;
565
+ case "get_habit":
566
+ response = enforcer.getHabit(request.params?.name);
567
+ break;
568
+ case "tool_tick":
569
+ response = enforcer.toolTick(
570
+ request.params?.session_id || "default",
571
+ request.params?.tool || ""
572
+ );
573
+ break;
574
+ case "submit_ack":
575
+ response = enforcer.submitAck(
576
+ request.params?.session_id || "default",
577
+ request.params?.statement || ""
578
+ );
579
+ break;
580
+ default:
581
+ response = { error: "unknown method" };
582
+ }
583
+
584
+ socket.write(JSON.stringify(response) + "\n");
585
+ } catch (err) {
586
+ socket.write(JSON.stringify({ error: "invalid request" }) + "\n");
587
+ }
588
+ }
589
+ });
590
+
591
+ socket.on("error", (err) => {
592
+ console.error("Socket error:", err);
593
+ });
594
+ });
595
+
596
+ // Cross-platform transport:
597
+ // - ENFORCER_SOCKET="tcp://127.0.0.1:8753" -> TCP (Windows, or any host)
598
+ // - ENFORCER_SOCKET unset -> Unix socket under AGENT_WORKSPACE/.agent/
599
+ // (portable: works on Linux, macOS, Windows-Subsystem, no /run needed)
600
+ // Same enforcement core, same wire protocol, on every OS.
601
+ // NOTE: Node's net.Server.listen() takes a string as a UNIX path; it does
602
+ // NOT parse "tcp://". So we split TCP into {host, port} explicitly.
603
+ const raw = process.env.ENFORCER_SOCKET
604
+ || (enforcer.cfg.WORKSPACE && path.join(enforcer.cfg.WORKSPACE, ".agent", "enforcer.sock"))
605
+ || "/run/agent-enforcer/main.sock";
606
+ const isTcp = typeof raw === "string" && raw.startsWith("tcp://");
607
+ let tcpHost = "127.0.0.1", tcpPort = 8753;
608
+
609
+ if (isTcp) {
610
+ const u = new URL(raw);
611
+ tcpHost = u.hostname || "127.0.0.1";
612
+ tcpPort = parseInt(u.port, 10) || 8753;
613
+ }
614
+
615
+ const onListening = () => {
616
+ console.log(`ACK Enforcer daemon v${ACK_VERSION} listening on ${raw}`);
617
+ console.log("System-owned enforcement service started successfully.");
618
+ };
619
+
620
+ if (isTcp) {
621
+ server.listen(tcpPort, tcpHost, onListening);
622
+ } else {
623
+ server.listen(raw, () => {
624
+ // Socket is world-connectable by design: connecting does NOT bypass
625
+ // enforcement — every request is still gated. What the agent CANNOT do is
626
+ // kill, modify, or replace this root-owned process or its files.
627
+ try { fssync.chmodSync(raw, 0o666); } catch {}
628
+ onListening();
629
+ });
630
+ }
631
+
632
+ // If a stale socket file exists (e.g. previous instance killed with -9), remove
633
+ // it so listen() succeeds on respawn. Without this, RestartSec cycles spin on a
634
+ // locked socket and recovery blows past the 3-5s target. (TCP has no stale
635
+ // file; EADDRINUSE there means the port is taken — fail and let the supervisor
636
+ // handle it.)
637
+ server.on("error", (err) => {
638
+ if (err.code === "EADDRINUSE" && !isTcp) {
639
+ try {
640
+ fssync.unlinkSync(raw);
641
+ server.listen(raw, () => { try { fssync.chmodSync(raw, 0o666); } catch {} onListening(); });
642
+ return;
643
+ } catch {}
644
+ }
645
+ console.error("Failed to start enforcer socket server:", err);
646
+ console.error("Ensure the socket path exists/writable, or the TCP port is free.");
647
+ process.exit(1);
648
+ });
649
+
650
+ // Self-respawning: if the process is killed, systemd (RestartSec=3) brings it back.
651
+ // Defensive: ignore broken pipes so a dead client can't crash the daemon.
652
+ process.on("SIGPIPE", () => {});
653
+
654
+ // Watchdog (mirrors the harness reference enforcer's validation_loop):
655
+ // periodically re-validate the workspace and flag a stale heartbeat as
656
+ // tamper-evidence. Runs inside the daemon, so it survives even with no client.
657
+ const HEARTBEAT_STALE_THRESHOLD = 600; // 10 min, per reference impl
658
+ const VALIDATION_INTERVAL = 30000; // 30s
659
+ setInterval(() => {
660
+ const violations = enforcer.validate_workspace();
661
+ if (violations.length) {
662
+ enforcer._audit("watchdog", "validate_workspace", { denied: true, reason: violations.join("; ") });
663
+ console.error(`[watchdog] WORKSPACE_VIOLATION: ${violations.join("; ")}`);
664
+ }
665
+ if (enforcer.lastHeartbeat && Date.now() - enforcer.lastHeartbeat > HEARTBEAT_STALE_THRESHOLD * 1000) {
666
+ enforcer._audit("watchdog", "heartbeat", { denied: true, reason: "STALE_HEARTBEAT" });
667
+ console.error("[watchdog] STALE_HEARTBEAT: agent has not checked in");
668
+ }
669
+ }, VALIDATION_INTERVAL);
670
+
671
+ const shutdown = () => {
672
+ console.log("Shutting down enforcer daemon...");
673
+ server.close(() => {
674
+ console.log("Enforcer daemon stopped.");
675
+ process.exit(0);
676
+ });
677
+ };
678
+ process.on("SIGTERM", shutdown);
679
+ process.on("SIGINT", shutdown);
680
+ }
681
+
682
+ // ─── Bootstrap ──────────────────────────────────────────────────────────────────
683
+ const enforcer = new Enforcer();
684
+ startSocketServer(enforcer);
685
+
686
+ console.log("ACK Enforcer daemon v1.0.0 started successfully.");
687
+ console.log("Root-owned system daemon with automatic restart support.");
688
+ console.log(`Workspace: ${enforcer.cfg.WORKSPACE}`);
689
+ console.log(`Config: ${enforcer.cfg.CONSTITUTION}`);
690
+ console.log(`Habits directory: ${enforcer.cfg.HABITS_DIR}`);