soulguard 0.2.2 → 0.3.0

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 (2) hide show
  1. package/dist/index.js +448 -227
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4096,7 +4096,8 @@ var ownershipSchema = exports_external.object({
4096
4096
  mode: exports_external.string()
4097
4097
  });
4098
4098
  var daemonConfigSchema = exports_external.object({
4099
- channel: exports_external.string()
4099
+ channel: exports_external.string().optional(),
4100
+ syncIntervalSecs: exports_external.number().int().nonnegative().optional()
4100
4101
  }).passthrough();
4101
4102
  var soulguardConfigSchema = exports_external.object({
4102
4103
  version: exports_external.literal(1),
@@ -4948,7 +4949,7 @@ class StateTree {
4948
4949
  return this.flatFiles().filter((f) => f.status !== "unchanged");
4949
4950
  }
4950
4951
  stagedFiles() {
4951
- return this.flatFiles().filter((f) => f.stagedHash !== null || f.status === "deleted");
4952
+ return this.flatFiles().filter((f) => f.status !== "unchanged");
4952
4953
  }
4953
4954
  driftedEntities() {
4954
4955
  return collectDrifts(this.entities, this.protectOwnership);
@@ -6153,6 +6154,7 @@ ${approvalMessage}`;
6153
6154
  }
6154
6155
 
6155
6156
  // ../core/src/sdk/sync.ts
6157
+ import { dirname as dirname2 } from "node:path";
6156
6158
  async function sync(options) {
6157
6159
  const { tree, ops, config } = options;
6158
6160
  const drifts = tree.driftedEntities();
@@ -6182,8 +6184,38 @@ async function sync(options) {
6182
6184
  }
6183
6185
  }
6184
6186
  }
6187
+ let stagingCopiesCreated = 0;
6188
+ const protectFiles = tree.flatFiles().filter((f) => f.configTier === "protect");
6189
+ for (const file of protectFiles) {
6190
+ if (file.canonicalHash === null)
6191
+ continue;
6192
+ if (file.stagedHash !== null || file.status === "deleted")
6193
+ continue;
6194
+ const stagePath = stagingPath(file.path);
6195
+ const parentDir = dirname2(stagePath);
6196
+ if (parentDir !== "." && parentDir !== "/" && parentDir !== STAGING_DIR) {
6197
+ await ops.mkdir(parentDir);
6198
+ const defaultOwnership = config.defaultOwnership;
6199
+ if (defaultOwnership) {
6200
+ await ops.chown(parentDir, { user: defaultOwnership.user, group: defaultOwnership.group });
6201
+ await ops.chmod(parentDir, "755");
6202
+ }
6203
+ }
6204
+ const readResult = await ops.readFile(file.path);
6205
+ if (!readResult.ok)
6206
+ continue;
6207
+ const writeResult = await ops.writeFile(stagePath, readResult.value);
6208
+ if (writeResult.ok) {
6209
+ const defaultOwnership = config.defaultOwnership;
6210
+ if (defaultOwnership) {
6211
+ await ops.chown(stagePath, { user: defaultOwnership.user, group: defaultOwnership.group });
6212
+ await ops.chmod(stagePath, defaultOwnership.mode);
6213
+ }
6214
+ stagingCopiesCreated++;
6215
+ }
6216
+ }
6185
6217
  if (errors2.length > 0) {
6186
- return ok({ drifts, errors: errors2, git: undefined });
6218
+ return ok({ drifts, errors: errors2, git: undefined, stagingCopiesCreated });
6187
6219
  }
6188
6220
  let git;
6189
6221
  if (await isGitEnabled(ops, config)) {
@@ -6195,7 +6227,7 @@ async function sync(options) {
6195
6227
  }
6196
6228
  }
6197
6229
  }
6198
- return ok({ drifts, errors: errors2, git });
6230
+ return ok({ drifts, errors: errors2, git, stagingCopiesCreated });
6199
6231
  }
6200
6232
  // ../core/src/util/console-live.ts
6201
6233
  var import_picocolors = __toESM(require_picocolors(), 1);
@@ -6280,11 +6312,11 @@ function validateSelfProtection(pendingContents, deletedFiles = []) {
6280
6312
  return ok(undefined);
6281
6313
  }
6282
6314
  // ../core/src/sdk/apply.ts
6283
- import { dirname as dirname2 } from "node:path";
6315
+ import { dirname as dirname3 } from "node:path";
6284
6316
  function collectParentDirs(prefix, paths) {
6285
6317
  const dirs = new Set;
6286
6318
  for (const p of paths) {
6287
- const parent = dirname2(`${prefix}/${p}`);
6319
+ const parent = dirname3(`${prefix}/${p}`);
6288
6320
  if (parent !== prefix) {
6289
6321
  dirs.add(parent);
6290
6322
  }
@@ -6384,7 +6416,7 @@ async function apply(options) {
6384
6416
  }
6385
6417
  } else {
6386
6418
  if (file.status === "created") {
6387
- const parentDir = dirname2(file.path);
6419
+ const parentDir = dirname3(file.path);
6388
6420
  if (parentDir !== ".") {
6389
6421
  await ops.mkdir(parentDir);
6390
6422
  }
@@ -6432,7 +6464,7 @@ async function apply(options) {
6432
6464
  const defaultOwnership = tree.config.defaultOwnership;
6433
6465
  for (const file of nonDeletedFiles) {
6434
6466
  const stagePath = stagingPath(file.path);
6435
- const stageParent = dirname2(stagePath);
6467
+ const stageParent = dirname3(stagePath);
6436
6468
  if (stageParent !== STAGING_DIR) {
6437
6469
  await ops.mkdir(stageParent);
6438
6470
  }
@@ -6613,10 +6645,10 @@ class SyncCommand {
6613
6645
  const result = await sync(this.opts);
6614
6646
  if (!result.ok)
6615
6647
  return 1;
6616
- const { drifts, errors: errors2, git } = result.value;
6648
+ const { drifts, errors: errors2, git, stagingCopiesCreated } = result.value;
6617
6649
  this.out.heading(`Soulguard Sync — ${this.opts.ops.workspace}`);
6618
6650
  this.out.write("");
6619
- if (drifts.length === 0 && errors2.length === 0) {
6651
+ if (drifts.length === 0 && errors2.length === 0 && stagingCopiesCreated === 0) {
6620
6652
  this.out.success("Nothing to fix — all files ok.");
6621
6653
  this.reportGit(git);
6622
6654
  return 0;
@@ -6639,6 +6671,10 @@ class SyncCommand {
6639
6671
  this.out.write("");
6640
6672
  return 1;
6641
6673
  }
6674
+ if (stagingCopiesCreated > 0) {
6675
+ this.out.success(` Refreshed ${stagingCopiesCreated} staging ${stagingCopiesCreated === 1 ? "copy" : "copies"}.`);
6676
+ this.out.write("");
6677
+ }
6642
6678
  this.out.success("All files now ok.");
6643
6679
  this.reportGit(git);
6644
6680
  return 0;
@@ -6815,6 +6851,54 @@ class ResetCommand {
6815
6851
  return 0;
6816
6852
  }
6817
6853
  }
6854
+ // ../core/src/sdk/staging-ops.ts
6855
+ import { dirname as dirname4 } from "node:path";
6856
+ function isInProtectTier(path, protectPatterns2) {
6857
+ for (const pattern of protectPatterns2) {
6858
+ const normalized = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
6859
+ if (path === normalized || path === pattern)
6860
+ return true;
6861
+ if (path.startsWith(normalized + "/"))
6862
+ return true;
6863
+ }
6864
+ return false;
6865
+ }
6866
+ async function ensureStagingParentDir(ops, canonicalPath, ownership) {
6867
+ const stagePath = stagingPath(canonicalPath);
6868
+ const parentDir = dirname4(stagePath);
6869
+ if (parentDir === "." || parentDir === "/" || parentDir === STAGING_DIR) {
6870
+ return ok(undefined);
6871
+ }
6872
+ const mkdirResult = await ops.mkdir(parentDir);
6873
+ if (!mkdirResult.ok) {
6874
+ return err(`Cannot create parent directory ${parentDir}: ${mkdirResult.error.kind}`);
6875
+ }
6876
+ if (ownership) {
6877
+ await ops.chown(parentDir, { user: ownership.user, group: ownership.group });
6878
+ await ops.chmod(parentDir, "755");
6879
+ }
6880
+ return ok(undefined);
6881
+ }
6882
+ async function createStagingCopy(ops, path, ownership) {
6883
+ const stagePath = stagingPath(path);
6884
+ const parentResult = await ensureStagingParentDir(ops, path, ownership);
6885
+ if (!parentResult.ok)
6886
+ return parentResult;
6887
+ const readResult = await ops.readFile(path);
6888
+ if (!readResult.ok) {
6889
+ return err(`Cannot read ${path}: ${readResult.error.kind}`);
6890
+ }
6891
+ const writeResult = await ops.writeFile(stagePath, readResult.value);
6892
+ if (!writeResult.ok) {
6893
+ return err(`Cannot write staging copy ${stagePath}: ${writeResult.error.kind}`);
6894
+ }
6895
+ if (ownership) {
6896
+ await ops.chown(stagePath, { user: ownership.user, group: ownership.group });
6897
+ await ops.chmod(stagePath, ownership.mode);
6898
+ }
6899
+ return ok(undefined);
6900
+ }
6901
+
6818
6902
  // ../core/src/cli/tier-command.ts
6819
6903
  function formatSummary(paths) {
6820
6904
  const dirs = paths.filter((p) => p.endsWith("/")).length;
@@ -6972,6 +7056,10 @@ class TierCommand {
6972
7056
  this.out.error(`Failed to write config: ${writeResult.error.message}`);
6973
7057
  return 1;
6974
7058
  }
7059
+ const sgCopyResult = await createStagingCopy(ops, "soulguard.json", configResult.value.defaultOwnership ?? undefined);
7060
+ if (!sgCopyResult.ok) {
7061
+ this.out.warn(` Warning: staging copy failed for soulguard.json: ${sgCopyResult.error}`);
7062
+ }
6975
7063
  if (action.kind === "set" && action.tier === "protect") {
6976
7064
  const expectedProtectOwnership = getProtectOwnership(configResult.value.guardian);
6977
7065
  for (const file of changedPaths) {
@@ -6981,6 +7069,34 @@ class TierCommand {
6981
7069
  return 1;
6982
7070
  }
6983
7071
  }
7072
+ const defaultOwnership = configResult.value.defaultOwnership;
7073
+ await ops.mkdir(".soulguard-staging");
7074
+ if (defaultOwnership) {
7075
+ await ops.chown(".soulguard-staging", {
7076
+ user: defaultOwnership.user,
7077
+ group: defaultOwnership.group
7078
+ });
7079
+ await ops.chmod(".soulguard-staging", "755");
7080
+ }
7081
+ for (const file of changedPaths) {
7082
+ const isDir = await isDirectory(ops, file);
7083
+ if (isDir) {
7084
+ const listResult = await ops.listDir(file);
7085
+ if (listResult.ok) {
7086
+ for (const childPath of listResult.value) {
7087
+ const copyResult = await createStagingCopy(ops, childPath, defaultOwnership ?? undefined);
7088
+ if (!copyResult.ok) {
7089
+ this.out.warn(` Warning: staging copy failed for ${childPath}: ${copyResult.error}`);
7090
+ }
7091
+ }
7092
+ }
7093
+ } else {
7094
+ const copyResult = await createStagingCopy(ops, file, defaultOwnership ?? undefined);
7095
+ if (!copyResult.ok) {
7096
+ this.out.warn(` Warning: staging copy failed for ${file}: ${copyResult.error}`);
7097
+ }
7098
+ }
7099
+ }
6984
7100
  } else if (action.kind === "set" && action.tier === "watch") {
6985
7101
  const defaultOwnership = configResult.value.defaultOwnership;
6986
7102
  for (const file of changedPaths) {
@@ -7018,153 +7134,114 @@ class TierCommand {
7018
7134
  return 0;
7019
7135
  }
7020
7136
  }
7021
- // ../core/src/sdk/stage.ts
7022
- import { dirname as dirname3 } from "node:path";
7023
- function isInProtectTier(path, protectPatterns2) {
7024
- for (const pattern of protectPatterns2) {
7025
- const normalized = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
7026
- if (path === normalized || path === pattern)
7027
- return true;
7028
- if (path.startsWith(normalized + "/"))
7029
- return true;
7137
+ // ../core/src/sdk/create.ts
7138
+ async function create(options) {
7139
+ const { ops, config, path } = options;
7140
+ if (!isInProtectTier(path, protectPatterns(config))) {
7141
+ return err({ kind: "not_in_protect_tier", path });
7030
7142
  }
7031
- return false;
7032
- }
7033
- async function ensureParentDir(ops, path) {
7034
- const parentDir = dirname3(path);
7035
- if (parentDir === "." || parentDir === "/") {
7036
- return ok(undefined);
7143
+ const exists = await ops.exists(path);
7144
+ if (exists.ok && exists.value) {
7145
+ return ok({ path, alreadyExisted: true });
7037
7146
  }
7038
- const mkdirResult = await ops.mkdir(parentDir);
7039
- if (!mkdirResult.ok) {
7040
- return err(`Cannot create parent directory ${parentDir}: ${mkdirResult.error.kind}`);
7147
+ const stagePath = stagingPath(path);
7148
+ const parentResult = await ensureStagingParentDir(ops, path);
7149
+ if (!parentResult.ok) {
7150
+ return err({ kind: "create_failed", path, message: parentResult.error });
7041
7151
  }
7042
- return ok(undefined);
7043
- }
7044
- async function stage(options) {
7045
- const { ops, config, path, delete: isDelete } = options;
7046
- const protectFiles = protectPatterns(config);
7047
- if (!isInProtectTier(path, protectFiles)) {
7048
- return err({ kind: "not_in_protect_tier", path });
7049
- }
7050
- const stagedFiles = [];
7051
- const pathStat = await ops.stat(path);
7052
- if (!pathStat.ok && pathStat.error.kind !== "not_found") {
7152
+ const writeResult = await ops.writeFile(stagePath, "");
7153
+ if (!writeResult.ok) {
7053
7154
  return err({
7054
- kind: "stage_failed",
7155
+ kind: "create_failed",
7055
7156
  path,
7056
- message: `Cannot stat path: ${pathStat.error.kind}`
7157
+ message: `Cannot create staging file: ${writeResult.error.kind}`
7057
7158
  });
7058
7159
  }
7059
- const isDirectory2 = pathStat.ok && pathStat.value.isDirectory;
7060
- if (isDirectory2 && isDelete) {
7061
- const stagePath = stagingPath(path);
7062
- const existsResult = await ops.exists(stagePath);
7063
- if (existsResult.ok && existsResult.value) {
7064
- const readResult = await ops.readFile(stagePath);
7065
- if (readResult.ok && isDeleteSentinel(readResult.value)) {
7066
- return ok({ stagedFiles: [] });
7067
- }
7068
- }
7069
- const parentResult = await ensureParentDir(ops, stagePath);
7070
- if (!parentResult.ok) {
7071
- return err({ kind: "stage_failed", path, message: parentResult.error });
7072
- }
7073
- const writeResult = await ops.writeFile(stagePath, JSON.stringify(DELETE_SENTINEL, null, 2));
7074
- if (!writeResult.ok) {
7075
- return err({
7076
- kind: "stage_failed",
7077
- path,
7078
- message: `Cannot write delete sentinel: ${writeResult.error.kind}`
7079
- });
7080
- }
7081
- stagedFiles.push({ path, action: "delete" });
7082
- } else if (isDirectory2 && !isDelete) {
7083
- const listResult = await ops.listDir(path);
7084
- if (!listResult.ok) {
7085
- return err({
7086
- kind: "stage_failed",
7087
- path,
7088
- message: `Cannot list directory: ${listResult.error.kind}`
7089
- });
7160
+ return ok({ path, alreadyExisted: false });
7161
+ }
7162
+
7163
+ // ../core/src/cli/create-command.ts
7164
+ class CreateCommand {
7165
+ opts;
7166
+ out;
7167
+ constructor(opts, out) {
7168
+ this.opts = opts;
7169
+ this.out = out;
7170
+ }
7171
+ async execute() {
7172
+ const { ops, config, paths } = this.opts;
7173
+ if (paths.length === 0) {
7174
+ this.out.error("No paths specified.");
7175
+ return 1;
7090
7176
  }
7091
- for (const filePath of listResult.value) {
7092
- const result = await stage({ ops, config, path: filePath, delete: isDelete });
7177
+ let created = 0;
7178
+ for (const path of paths) {
7179
+ const result = await create({ ops, config, path });
7093
7180
  if (!result.ok) {
7094
- return result;
7095
- }
7096
- stagedFiles.push(...result.value.stagedFiles);
7097
- }
7098
- } else if (!isDirectory2 && isDelete) {
7099
- const stagePath = stagingPath(path);
7100
- const existsResult = await ops.exists(stagePath);
7101
- if (existsResult.ok && existsResult.value) {
7102
- const readResult = await ops.readFile(stagePath);
7103
- if (readResult.ok && isDeleteSentinel(readResult.value)) {
7104
- return ok({ stagedFiles: [] });
7181
+ switch (result.error.kind) {
7182
+ case "not_in_protect_tier":
7183
+ this.out.error(`${result.error.path} is not in the protect tier.`);
7184
+ return 1;
7185
+ case "create_failed":
7186
+ this.out.error(`Failed to create ${result.error.path}: ${result.error.message}`);
7187
+ return 1;
7188
+ }
7105
7189
  }
7106
- }
7107
- const parentResult = await ensureParentDir(ops, stagePath);
7108
- if (!parentResult.ok) {
7109
- return err({ kind: "stage_failed", path, message: parentResult.error });
7110
- }
7111
- const writeResult = await ops.writeFile(stagePath, JSON.stringify(DELETE_SENTINEL, null, 2));
7112
- if (!writeResult.ok) {
7113
- return err({
7114
- kind: "stage_failed",
7115
- path,
7116
- message: `Cannot write delete sentinel: ${writeResult.error.kind}`
7117
- });
7118
- }
7119
- stagedFiles.push({ path, action: "delete" });
7120
- } else if (!isDirectory2 && !isDelete) {
7121
- const stagePath = stagingPath(path);
7122
- const existsResult = await ops.exists(stagePath);
7123
- if (existsResult.ok && existsResult.value) {
7124
- const readResult = await ops.readFile(stagePath);
7125
- if (readResult.ok && isDeleteSentinel(readResult.value)) {} else {
7126
- return ok({ stagedFiles: [] });
7190
+ if (result.value.alreadyExisted) {
7191
+ this.out.warn(` · ${path} (already exists staging copy was created automatically)`);
7192
+ continue;
7127
7193
  }
7194
+ this.out.success(` + ${path} → ${stagingPath(path)}`);
7195
+ created++;
7128
7196
  }
7129
- const parentResult = await ensureParentDir(ops, stagePath);
7130
- if (!parentResult.ok) {
7131
- return err({ kind: "stage_failed", path, message: parentResult.error });
7132
- }
7133
- const sourceExists = await ops.exists(path);
7134
- if (sourceExists.ok && sourceExists.value) {
7135
- const readResult = await ops.readFile(path);
7136
- if (!readResult.ok) {
7137
- return err({
7138
- kind: "stage_failed",
7139
- path,
7140
- message: `Cannot read file: ${readResult.error.kind}`
7141
- });
7142
- }
7143
- const writeResult = await ops.writeFile(stagePath, readResult.value);
7144
- if (!writeResult.ok) {
7145
- return err({
7146
- kind: "stage_failed",
7147
- path,
7148
- message: `Cannot write staging copy: ${writeResult.error.kind}`
7149
- });
7150
- }
7197
+ if (created === 0) {
7198
+ this.out.info("Nothing to create.");
7151
7199
  } else {
7152
- const writeResult = await ops.writeFile(stagePath, "");
7153
- if (!writeResult.ok) {
7154
- return err({
7155
- kind: "stage_failed",
7156
- path,
7157
- message: `Cannot create empty staging file: ${writeResult.error.kind}`
7158
- });
7159
- }
7200
+ this.out.write("");
7201
+ this.out.success(`Created ${created} staging ${created === 1 ? "entry" : "entries"}.`);
7160
7202
  }
7161
- stagedFiles.push({ path, action: "edit" });
7203
+ return 0;
7162
7204
  }
7163
- return ok({ stagedFiles });
7205
+ }
7206
+ // ../core/src/sdk/delete.ts
7207
+ async function deleteStagingEntry(options) {
7208
+ const { ops, config, path } = options;
7209
+ if (!isInProtectTier(path, protectPatterns(config))) {
7210
+ return err({ kind: "not_in_protect_tier", path });
7211
+ }
7212
+ const diskExists = await ops.exists(path);
7213
+ const stagePath = stagingPath(path);
7214
+ const stagingExists = await ops.exists(stagePath);
7215
+ if ((!diskExists.ok || !diskExists.value) && (!stagingExists.ok || !stagingExists.value)) {
7216
+ return err({ kind: "not_found", path });
7217
+ }
7218
+ if (stagingExists.ok && stagingExists.value) {
7219
+ const readResult = await ops.readFile(stagePath);
7220
+ if (readResult.ok && isDeleteSentinel(readResult.value)) {
7221
+ return err({ kind: "already_deleted", path });
7222
+ }
7223
+ }
7224
+ const stageExists = await ops.stat(stagePath);
7225
+ if (stageExists.ok && stageExists.value.isDirectory) {
7226
+ await ops.exec("rm", ["-rf", stagePath]);
7227
+ }
7228
+ const parentResult = await ensureStagingParentDir(ops, path);
7229
+ if (!parentResult.ok) {
7230
+ return err({ kind: "delete_failed", path, message: parentResult.error });
7231
+ }
7232
+ const writeResult = await ops.writeFile(stagePath, JSON.stringify(DELETE_SENTINEL, null, 2));
7233
+ if (!writeResult.ok) {
7234
+ return err({
7235
+ kind: "delete_failed",
7236
+ path,
7237
+ message: `Cannot write delete sentinel: ${writeResult.error.kind}`
7238
+ });
7239
+ }
7240
+ return ok({ path });
7164
7241
  }
7165
7242
 
7166
- // ../core/src/cli/stage-command.ts
7167
- class StageCommand {
7243
+ // ../core/src/cli/delete-command.ts
7244
+ class DeleteCommand {
7168
7245
  opts;
7169
7246
  out;
7170
7247
  constructor(opts, out) {
@@ -7172,47 +7249,38 @@ class StageCommand {
7172
7249
  this.out = out;
7173
7250
  }
7174
7251
  async execute() {
7175
- const { ops, config, paths, delete: isDelete } = this.opts;
7252
+ const { ops, config, paths } = this.opts;
7176
7253
  if (paths.length === 0) {
7177
7254
  this.out.error("No paths specified.");
7178
7255
  return 1;
7179
7256
  }
7180
- const allStagedFiles = [];
7181
- const alreadyStaged = [];
7257
+ let deleted = 0;
7182
7258
  for (const path of paths) {
7183
- const result = await stage({ ops, config, path, delete: isDelete });
7259
+ const result = await deleteStagingEntry({ ops, config, path });
7184
7260
  if (!result.ok) {
7185
7261
  switch (result.error.kind) {
7186
7262
  case "not_in_protect_tier":
7187
7263
  this.out.error(`${result.error.path} is not in the protect tier.`);
7188
7264
  return 1;
7189
- case "stage_failed":
7190
- this.out.error(`Failed to stage ${result.error.path}: ${result.error.message}`);
7265
+ case "not_found":
7266
+ this.out.error(`${result.error.path} does not exist.`);
7267
+ return 1;
7268
+ case "already_deleted":
7269
+ this.out.info(` · ${result.error.path} (already staged for deletion)`);
7270
+ continue;
7271
+ case "delete_failed":
7272
+ this.out.error(`Failed to delete ${result.error.path}: ${result.error.message}`);
7191
7273
  return 1;
7192
7274
  }
7193
7275
  }
7194
- if (result.value.stagedFiles.length === 0) {
7195
- alreadyStaged.push(path);
7196
- } else {
7197
- allStagedFiles.push(...result.value.stagedFiles);
7198
- }
7199
- }
7200
- for (const file of alreadyStaged) {
7201
- this.out.info(` · ${file} (already staged)`);
7202
- }
7203
- for (const { path, action } of allStagedFiles) {
7204
- const staged = stagingPath(path);
7205
- if (action === "delete") {
7206
- this.out.success(` \uD83D\uDDD1️ ${path} (staged for deletion)`);
7207
- } else {
7208
- this.out.success(` \uD83D\uDCDD ${path} → ${staged}`);
7209
- }
7276
+ this.out.success(` \uD83D\uDDD1️ ${path} (staged for deletion)`);
7277
+ deleted++;
7210
7278
  }
7211
- if (allStagedFiles.length === 0) {
7212
- this.out.info("Nothing to stage.");
7279
+ if (deleted === 0) {
7280
+ this.out.info("Nothing to do.");
7213
7281
  } else {
7214
7282
  this.out.write("");
7215
- this.out.success(`Staged ${allStagedFiles.length} file(s).`);
7283
+ this.out.success(`Staged ${deleted} ${deleted === 1 ? "path" : "paths"} for deletion.`);
7216
7284
  }
7217
7285
  return 0;
7218
7286
  }
@@ -7331,17 +7399,11 @@ class ProposalManager extends EventEmitter {
7331
7399
  if (!freshTree.ok) {
7332
7400
  const error = new Error(`Failed to build fresh state tree: ${freshTree.error.message}`);
7333
7401
  this.emit("error", error, "approval:verification");
7334
- proposal.state = "rejected";
7335
- this._activeProposal = null;
7336
- await this._channel.postResult(proposal.externalId, "rejected");
7337
- this.emit("rejected", proposal);
7402
+ await this._rejectProposal(proposal);
7338
7403
  return;
7339
7404
  }
7340
7405
  if (freshTree.value.approvalHash !== proposal.payload.hash) {
7341
- proposal.state = "rejected";
7342
- this._activeProposal = null;
7343
- await this._channel.postResult(proposal.externalId, "rejected");
7344
- this.emit("rejected", proposal);
7406
+ await this._rejectProposal(proposal);
7345
7407
  return;
7346
7408
  }
7347
7409
  const applyResult = await apply({
@@ -7352,10 +7414,7 @@ class ProposalManager extends EventEmitter {
7352
7414
  if (!applyResult.ok) {
7353
7415
  const error = new Error(`Apply failed: ${applyResult.error.kind}`);
7354
7416
  this.emit("error", error, "approval:apply");
7355
- proposal.state = "rejected";
7356
- this._activeProposal = null;
7357
- await this._channel.postResult(proposal.externalId, "rejected");
7358
- this.emit("rejected", proposal);
7417
+ await this._rejectProposal(proposal);
7359
7418
  return;
7360
7419
  }
7361
7420
  proposal.state = "approved";
@@ -7363,10 +7422,7 @@ class ProposalManager extends EventEmitter {
7363
7422
  await this._channel.postResult(proposal.externalId, "applied");
7364
7423
  this.emit("applied", proposal);
7365
7424
  } else {
7366
- proposal.state = "rejected";
7367
- this._activeProposal = null;
7368
- await this._channel.postResult(proposal.externalId, "rejected");
7369
- this.emit("rejected", proposal);
7425
+ await this._rejectProposal(proposal);
7370
7426
  }
7371
7427
  } catch (e) {
7372
7428
  if (e instanceof DOMException && e.name === "AbortError")
@@ -7375,15 +7431,21 @@ class ProposalManager extends EventEmitter {
7375
7431
  return;
7376
7432
  const error = e instanceof Error ? e : new Error(String(e));
7377
7433
  this.emit("error", error, "approval:wait");
7378
- proposal.state = "rejected";
7379
- this._activeProposal = null;
7434
+ await this._rejectProposal(proposal);
7380
7435
  }
7381
7436
  }
7437
+ async _rejectProposal(proposal) {
7438
+ proposal.state = "rejected";
7439
+ await this._resetStaging(proposal);
7440
+ this._pendingProposalHash = null;
7441
+ this._activeProposal = null;
7442
+ await this._channel.postResult(proposal.externalId, "rejected");
7443
+ this.emit("rejected", proposal);
7444
+ }
7382
7445
  async _supersedePending() {
7383
7446
  if (this._activeProposal && this._abortController) {
7384
7447
  const oldProposal = this._activeProposal;
7385
7448
  const oldController = this._abortController;
7386
- oldProposal.state = "superseded";
7387
7449
  this._activeProposal = null;
7388
7450
  this._abortController = null;
7389
7451
  oldController.abort();
@@ -7391,6 +7453,9 @@ class ProposalManager extends EventEmitter {
7391
7453
  await this._pendingFlow.catch(() => {});
7392
7454
  this._pendingFlow = null;
7393
7455
  }
7456
+ if (oldProposal.state !== "pending")
7457
+ return;
7458
+ oldProposal.state = "superseded";
7394
7459
  await this._channel.postResult(oldProposal.externalId, "superseded");
7395
7460
  this.emit("superseded", oldProposal);
7396
7461
  }
@@ -7405,7 +7470,19 @@ class ProposalManager extends EventEmitter {
7405
7470
  this._activeProposal = null;
7406
7471
  this._abortController = null;
7407
7472
  }
7473
+ async _resetStaging(proposal) {
7474
+ const defaultOwnership = this._config.defaultOwnership;
7475
+ for (const file of proposal.payload.files) {
7476
+ const result = file.status === "created" ? await this._ops.deleteFile(stagingPath(file.path)) : await createStagingCopy(this._ops, file.path, defaultOwnership);
7477
+ if (!result.ok) {
7478
+ this.emit("error", new Error(`Reset staging failed for ${file.path}: ${String(result.error)}`), `resetStaging:${file.path}`);
7479
+ }
7480
+ }
7481
+ }
7408
7482
  }
7483
+ // ../core/src/daemon/daemon.ts
7484
+ import { EventEmitter as EventEmitter2 } from "node:events";
7485
+
7409
7486
  // ../core/src/daemon/channel-registry.ts
7410
7487
  var REGISTRY_KEY = "__soulguard_channel_registry__";
7411
7488
  function getRegistry() {
@@ -7423,15 +7500,23 @@ function getChannel(name) {
7423
7500
  }
7424
7501
 
7425
7502
  // ../core/src/daemon/daemon.ts
7426
- class SoulguardDaemon {
7503
+ class SoulguardDaemon extends EventEmitter2 {
7427
7504
  _ops;
7428
7505
  _config;
7506
+ _channelOverride;
7507
+ _maxProposals;
7429
7508
  _channel = null;
7430
7509
  _proposalManager = null;
7510
+ _syncTimer = null;
7511
+ _syncRunning = false;
7431
7512
  _running = false;
7513
+ _doneResolve = null;
7432
7514
  constructor(options) {
7515
+ super();
7433
7516
  this._ops = options.ops;
7434
7517
  this._config = options.config;
7518
+ this._channelOverride = options.channelOverride ?? null;
7519
+ this._maxProposals = options.maxProposals ?? null;
7435
7520
  }
7436
7521
  get running() {
7437
7522
  return this._running;
@@ -7446,37 +7531,104 @@ class SoulguardDaemon {
7446
7531
  if (!daemonConfig) {
7447
7532
  throw new Error("Daemon configuration missing. Add a 'daemon' section to soulguard.json.");
7448
7533
  }
7449
- const channelName = daemonConfig.channel;
7450
- console.log(`[daemon] channel: "${channelName}", config keys: ${JSON.stringify(Object.keys(daemonConfig))}`);
7451
- const createChannelFn = getChannel(channelName);
7452
- if (!createChannelFn) {
7453
- throw new Error(`No channel registered for "${channelName}". Register it with registerChannel() before starting the daemon.`);
7454
- }
7455
- const channelConfig = daemonConfig[channelName];
7456
- console.log(`[daemon] channelConfig present: ${!!channelConfig}, keys: ${channelConfig ? JSON.stringify(Object.keys(channelConfig)) : "n/a"}`);
7457
- this._channel = createChannelFn(channelConfig);
7458
- console.log(`[daemon] channel created: ${this._channel.name}`);
7459
- this._proposalManager = new ProposalManager({
7460
- ops: this._ops,
7461
- config: this._config,
7462
- channel: this._channel
7463
- });
7464
- console.log(`[daemon] starting proposal manager`);
7465
- this._proposalManager.start();
7534
+ if (this._channelOverride) {
7535
+ this._channel = this._channelOverride;
7536
+ } else {
7537
+ const channelName = daemonConfig.channel;
7538
+ if (channelName) {
7539
+ const createChannelFn = getChannel(channelName);
7540
+ if (!createChannelFn) {
7541
+ throw new Error(`No channel registered for "${channelName}". Register it with registerChannel() before starting the daemon.`);
7542
+ }
7543
+ const channelConfig = daemonConfig[channelName];
7544
+ this._channel = createChannelFn(channelConfig);
7545
+ }
7546
+ }
7547
+ if (this._channel) {
7548
+ this._proposalManager = new ProposalManager({
7549
+ ops: this._ops,
7550
+ config: this._config,
7551
+ channel: this._channel
7552
+ });
7553
+ this._proposalManager.on("proposed", (...args) => this.emit("proposed", ...args));
7554
+ this._proposalManager.on("applied", (...args) => this.emit("applied", ...args));
7555
+ this._proposalManager.on("rejected", (...args) => this.emit("rejected", ...args));
7556
+ this._proposalManager.on("superseded", (...args) => this.emit("superseded", ...args));
7557
+ this._proposalManager.on("error", (...args) => this.emit("proposal:error", ...args));
7558
+ if (this._maxProposals != null) {
7559
+ let count = 0;
7560
+ const checkDone = () => {
7561
+ count++;
7562
+ if (count >= this._maxProposals) {
7563
+ this.stop();
7564
+ }
7565
+ };
7566
+ this._proposalManager.on("applied", checkDone);
7567
+ this._proposalManager.on("rejected", checkDone);
7568
+ }
7569
+ this._proposalManager.start();
7570
+ }
7571
+ const syncIntervalSecs = daemonConfig.syncIntervalSecs ?? 60;
7572
+ if (syncIntervalSecs > 0) {
7573
+ this._runSync();
7574
+ this._syncTimer = setInterval(() => this._runSync(), syncIntervalSecs * 1000);
7575
+ }
7466
7576
  this._running = true;
7467
7577
  }
7578
+ done() {
7579
+ if (!this._running)
7580
+ return Promise.resolve();
7581
+ return new Promise((resolve3) => {
7582
+ this._doneResolve = resolve3;
7583
+ });
7584
+ }
7468
7585
  async stop() {
7469
7586
  if (!this._running)
7470
7587
  return;
7471
7588
  this._running = false;
7589
+ if (this._syncTimer) {
7590
+ clearInterval(this._syncTimer);
7591
+ this._syncTimer = null;
7592
+ }
7472
7593
  if (this._proposalManager) {
7473
7594
  await this._proposalManager.stop();
7474
7595
  this._proposalManager = null;
7475
7596
  }
7476
- if (this._channel) {
7597
+ if (this._channel && !this._channelOverride) {
7477
7598
  await this._channel.dispose();
7478
7599
  this._channel = null;
7479
7600
  }
7601
+ this._doneResolve?.();
7602
+ this._doneResolve = null;
7603
+ }
7604
+ async _runSync() {
7605
+ if (this._syncRunning)
7606
+ return;
7607
+ this._syncRunning = true;
7608
+ try {
7609
+ const treeResult = await StateTree.build({
7610
+ ops: this._ops,
7611
+ config: this._config
7612
+ });
7613
+ if (!treeResult.ok) {
7614
+ this.emit("sync:error", new Error(`StateTree.build failed: ${treeResult.error.message}`));
7615
+ return;
7616
+ }
7617
+ const syncResult = await sync({
7618
+ tree: treeResult.value,
7619
+ ops: this._ops,
7620
+ config: this._config
7621
+ });
7622
+ if (!syncResult.ok) {
7623
+ this.emit("sync:error", new Error(`sync failed: ${syncResult.error.message}`));
7624
+ return;
7625
+ }
7626
+ this.emit("synced", syncResult.value);
7627
+ } catch (e) {
7628
+ this.emit("sync:error", e instanceof Error ? e : new Error(String(e)));
7629
+ } finally {
7630
+ this._syncRunning = false;
7631
+ }
7480
7632
  }
7481
7633
  }
7482
7634
  // ../core/src/daemon/service.ts
@@ -7629,8 +7781,9 @@ var templates = {
7629
7781
  };
7630
7782
  // ../openclaw/src/plugin.ts
7631
7783
  import { readFileSync } from "node:fs";
7784
+ import { access as access2, chmod, copyFile, mkdir } from "node:fs/promises";
7632
7785
  import os from "node:os";
7633
- import { join as join2 } from "node:path";
7786
+ import { dirname as dirname5, join as join2 } from "node:path";
7634
7787
 
7635
7788
  // ../openclaw/src/guard.ts
7636
7789
  import path from "node:path";
@@ -7638,40 +7791,57 @@ var WRITE_TOOLS = new Set(["write", "edit"]);
7638
7791
  var PATH_KEYS = ["file_path", "path", "file"];
7639
7792
  function guardToolCall(toolName, params, options) {
7640
7793
  if (!WRITE_TOOLS.has(toolName.toLowerCase())) {
7641
- return { blocked: false };
7794
+ return { action: "allow" };
7642
7795
  }
7643
- let targetPath;
7796
+ let pathKey;
7797
+ let rawPath;
7644
7798
  for (const key of PATH_KEYS) {
7645
7799
  const v = params[key];
7646
7800
  if (typeof v === "string" && v.length > 0) {
7647
- targetPath = v;
7801
+ pathKey = key;
7802
+ rawPath = v;
7648
7803
  break;
7649
7804
  }
7650
7805
  }
7651
- if (targetPath && path.isAbsolute(targetPath)) {
7652
- targetPath = path.relative(options.stateDir, targetPath);
7653
- }
7654
- if (!targetPath)
7655
- return { blocked: false };
7656
- if (isStagingPath(targetPath))
7657
- return { blocked: false };
7658
- if (!isProtectedFile(options.protectFiles, targetPath))
7659
- return { blocked: false };
7806
+ if (!pathKey || !rawPath)
7807
+ return { action: "allow" };
7808
+ const isAbsolute = path.isAbsolute(rawPath);
7809
+ const relativePath = isAbsolute ? path.relative(options.stateDir, rawPath) : rawPath;
7810
+ if (isStagingPath(relativePath))
7811
+ return { action: "allow" };
7812
+ if (!isProtectedFile(options.protectFiles, relativePath))
7813
+ return { action: "allow" };
7814
+ const relativeStaging = stagingPath(relativePath);
7815
+ const redirectedPath = isAbsolute ? path.join(options.stateDir, relativeStaging) : relativeStaging;
7660
7816
  return {
7661
- blocked: true,
7662
- reason: [
7663
- `${targetPath} is protected by soulguard.`,
7664
- `To propose changes, run \`soulguard stage ${targetPath}\` to create a working copy,`,
7665
- `then edit the staged file at ${stagingPath(targetPath)}.`,
7666
- `Run \`soulguard diff\` to review your changes.`,
7667
- `Your owner will review and apply the changes.`
7668
- ].join(" ")
7817
+ action: "redirect",
7818
+ pathKey,
7819
+ originalPath: relativePath,
7820
+ redirectedPath
7669
7821
  };
7670
7822
  }
7671
7823
 
7672
7824
  // ../openclaw/src/plugin.ts
7673
7825
  var PKG_VERSION = typeof SOULGUARD_VERSION !== "undefined" ? SOULGUARD_VERSION : "0.0.0-dev";
7674
7826
  var PLUGIN_DESCRIPTION = "Identity protection for AI agents";
7827
+ var MAX_TRACKED_REDIRECTS = 1024;
7828
+ var redirectMap = new Map;
7829
+ async function ensureStagingCopy(originalAbsPath, stagingAbsPath) {
7830
+ try {
7831
+ await access2(stagingAbsPath);
7832
+ return;
7833
+ } catch {}
7834
+ await mkdir(dirname5(stagingAbsPath), { recursive: true });
7835
+ try {
7836
+ await copyFile(originalAbsPath, stagingAbsPath);
7837
+ await chmod(stagingAbsPath, 420);
7838
+ } catch {}
7839
+ }
7840
+ function buildRedirectWarning(info) {
7841
+ return `
7842
+
7843
+ [Soulguard] This edit was redirected to the staging copy at ${info.redirectedPath}. ` + `The original file ${info.originalPath} is protected. ` + "Run `soulguard diff` to review your changes. " + "Your owner will review and apply them.";
7844
+ }
7675
7845
  function createSoulguardPlugin(options) {
7676
7846
  return {
7677
7847
  id: "soulguard",
@@ -7692,7 +7862,7 @@ function createSoulguardPlugin(options) {
7692
7862
  }
7693
7863
  if (protectFiles.length === 0)
7694
7864
  return;
7695
- api.on("before_tool_call", (...args) => {
7865
+ api.on("before_tool_call", async (...args) => {
7696
7866
  const event = args[0];
7697
7867
  if (!event || typeof event !== "object" || !("toolName" in event)) {
7698
7868
  return;
@@ -7702,11 +7872,57 @@ function createSoulguardPlugin(options) {
7702
7872
  protectFiles,
7703
7873
  stateDir
7704
7874
  });
7705
- if (result.blocked) {
7875
+ if (result.action === "block") {
7706
7876
  return { block: true, blockReason: result.reason };
7707
7877
  }
7878
+ if (result.action === "redirect") {
7879
+ const originalAbsPath = join2(stateDir, result.originalPath);
7880
+ const stagingAbsPath = result.redirectedPath.startsWith("/") ? result.redirectedPath : join2(stateDir, result.redirectedPath);
7881
+ await ensureStagingCopy(originalAbsPath, stagingAbsPath);
7882
+ const toolCallId = e.toolCallId;
7883
+ if (toolCallId) {
7884
+ if (redirectMap.size >= MAX_TRACKED_REDIRECTS) {
7885
+ const oldest = redirectMap.keys().next().value;
7886
+ if (oldest)
7887
+ redirectMap.delete(oldest);
7888
+ }
7889
+ redirectMap.set(toolCallId, {
7890
+ originalPath: result.originalPath,
7891
+ redirectedPath: result.redirectedPath
7892
+ });
7893
+ }
7894
+ return {
7895
+ params: { [result.pathKey]: result.redirectedPath }
7896
+ };
7897
+ }
7708
7898
  return;
7709
7899
  });
7900
+ api.on("tool_result_persist", (...args) => {
7901
+ const event = args[0];
7902
+ if (!event?.toolCallId || !event.message)
7903
+ return;
7904
+ const info = redirectMap.get(event.toolCallId);
7905
+ if (!info)
7906
+ return;
7907
+ redirectMap.delete(event.toolCallId);
7908
+ const warning = buildRedirectWarning(info);
7909
+ const msg = event.message;
7910
+ const content = Array.isArray(msg.content) ? [...msg.content] : [];
7911
+ let lastText;
7912
+ for (let i = content.length - 1;i >= 0; i--) {
7913
+ const block = content[i];
7914
+ if (block && block.type === "text") {
7915
+ lastText = block;
7916
+ break;
7917
+ }
7918
+ }
7919
+ if (lastText && lastText.text != null) {
7920
+ lastText.text += warning;
7921
+ } else {
7922
+ content.push({ type: "text", text: warning.trimStart() });
7923
+ }
7924
+ return { message: { ...msg, content } };
7925
+ });
7710
7926
  }
7711
7927
  };
7712
7928
  }
@@ -7752,6 +7968,7 @@ export {
7752
7968
  makeDefaultConfig,
7753
7969
  isStagingPath,
7754
7970
  isProtectedFile,
7971
+ isInProtectTier,
7755
7972
  isDeleteSentinel,
7756
7973
  guardianName,
7757
7974
  getProtectOwnership,
@@ -7762,13 +7979,15 @@ export {
7762
7979
  evaluatePolicies,
7763
7980
  err,
7764
7981
  diff,
7982
+ deleteStagingEntry,
7983
+ createStagingCopy,
7984
+ create,
7765
7985
  apply,
7766
7986
  TierCommand,
7767
7987
  SyncCommand,
7768
7988
  StatusCommand,
7769
7989
  StateTreeBuildError,
7770
7990
  StateTree,
7771
- StageCommand,
7772
7991
  SoulguardDaemon,
7773
7992
  STAGING_DIR,
7774
7993
  SOULGUARD_GROUP,
@@ -7778,6 +7997,8 @@ export {
7778
7997
  MockSystemOps,
7779
7998
  LiveConsoleOutput,
7780
7999
  DiffCommand,
8000
+ DeleteCommand,
7781
8001
  DELETE_SENTINEL,
8002
+ CreateCommand,
7782
8003
  ApplyCommand
7783
8004
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulguard",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Identity protection for AI agents",
5
5
  "homepage": "https://soulguard.ai",
6
6
  "license": "MIT",