From 42bac829e0336d73ea58e344e327563a6416cee8 Mon Sep 17 00:00:00 2001
From: Antisophy <293439221+Antisophy@users.noreply.github.com>
Date: Mon, 29 Jun 2026 18:07:21 -0700
Subject: [PATCH] feat(claude): trust the project of a permission_policy
"allow" task
Run non-interactively (cydo's -p mode), Claude leaves a directory untrusted and silently ignores its settings.local.json permissions.allow entries (and project hooks), warning "Ignoring N permissions.allow entries ... this workspace has not been trusted". Trust is keyed per-directory in ~/.claude.json (projects[
].hasTrustDialogAccepted), so each project a task runs in needs its own flag, not just the workspace root.
A workspace configured to auto-allow every tool call is already a statement of trust, so before launching a claude session whose workspace permission_policy is "allow", mark that task's working directory as a trusted project. Idempotent and atomic (merge into existing config, temp-file + rename, only when the flag is missing); never throws into session launch.
Covered by a hermetic unit test (temp paths) asserting the target project is trusted, existing config is preserved, and the write is idempotent.
---
source/cydo/agent/drivers/claude.d | 97 ++++++++++++++++++++++++++++++
1 file changed, 97 insertions(+)
diff --git a/source/cydo/agent/drivers/claude.d b/source/cydo/agent/drivers/claude.d
index c328cfb..f67d5f3 100644
--- a/source/cydo/agent/drivers/claude.d
+++ b/source/cydo/agent/drivers/claude.d
@@ -692,6 +692,97 @@ class ClaudeCodeAgent : Agent
}
}
+/// Path to the Claude Code config file the spawned `claude` child reads
+/// (honors CLAUDE_CONFIG_DIR, else ~/.claude.json).
+private string claudeJsonPath()
+{
+ import std.path : buildPath, expandTilde;
+ import std.process : environment;
+ auto dir = environment.get("CLAUDE_CONFIG_DIR", "");
+ return dir.length > 0 ? buildPath(dir, ".claude.json") : buildPath(expandTilde("~"), ".claude.json");
+}
+
+/// Mark `projectPath` as a trusted project in Claude Code's config
+/// (projects[projectPath].hasTrustDialogAccepted = true). Run non-interactively
+/// (cydo's `-p` mode), Claude leaves a directory untrusted and silently ignores
+/// its settings.local.json `permissions.allow` entries and project hooks; trust
+/// is keyed per-directory, so each project a task runs in needs its own flag.
+/// Idempotent and atomic: merges into the existing config, writes (temp + rename)
+/// only when the flag is missing, and never throws into session launch.
+private void ensureProjectTrusted(string projectPath, string claudeConfigPath)
+{
+ import std.file : exists, read, rename, write;
+ import std.json : JSONValue, JSONType, parseJSON;
+ import std.logger : warningf;
+
+ if (projectPath.length == 0)
+ return;
+ try
+ {
+ JSONValue root = exists(claudeConfigPath)
+ ? parseJSON(cast(string) read(claudeConfigPath))
+ : parseJSON("{}");
+ if (root.type != JSONType.object)
+ return;
+
+ JSONValue[string] projects;
+ if (auto p = "projects" in root.object)
+ if (p.type == JSONType.object)
+ projects = p.object.dup;
+
+ JSONValue proj = parseJSON("{}");
+ if (auto existing = projectPath in projects)
+ if (existing.type == JSONType.object)
+ proj = *existing;
+ if (auto t = "hasTrustDialogAccepted" in proj.object)
+ if (t.type == JSONType.true_)
+ return; // already trusted
+
+ proj.object["hasTrustDialogAccepted"] = JSONValue(true);
+ projects[projectPath] = proj;
+ root.object["projects"] = JSONValue(projects);
+
+ // atomic: write a sibling temp file, then rename over the original
+ auto tmp = claudeConfigPath ~ ".cydo-trust-tmp";
+ write(tmp, root.toString());
+ rename(tmp, claudeConfigPath);
+ }
+ catch (Exception e)
+ warningf("ensureProjectTrusted: could not update %s: %s", claudeConfigPath, e.msg);
+}
+
+unittest
+{
+ import std.file : exists, mkdirRecurse, rmdirRecurse, read, write, tempDir;
+ import std.path : buildPath;
+ import std.json : JSONType, parseJSON;
+
+ auto dir = buildPath(tempDir(), "cydo-project-trust-unittest");
+ if (exists(dir))
+ rmdirRecurse(dir);
+ mkdirRecurse(dir);
+ scope(exit) rmdirRecurse(dir);
+
+ auto cfg = buildPath(dir, "claude.json");
+ // pre-existing config: an unrelated key plus a sibling project already
+ // trusted, to prove the merge preserves rather than clobbers
+ write(cfg, `{"oauthAccount":{"id":1},"projects":{"/other/proj":{"hasTrustDialogAccepted":true}}}`);
+
+ ensureProjectTrusted("/ws/project", cfg);
+
+ auto root = parseJSON(cast(string) read(cfg));
+ // the target project is now trusted
+ assert(root["projects"]["/ws/project"]["hasTrustDialogAccepted"].type == JSONType.true_);
+ // pre-existing content survives the merge
+ assert(root["oauthAccount"]["id"].integer == 1);
+ assert(root["projects"]["/other/proj"]["hasTrustDialogAccepted"].type == JSONType.true_);
+
+ // idempotent: a second run neither errors nor regresses the flag
+ ensureProjectTrusted("/ws/project", cfg);
+ auto again = parseJSON(cast(string) read(cfg));
+ assert(again["projects"]["/ws/project"]["hasTrustDialogAccepted"].type == JSONType.true_);
+}
+
/// Claude Code session using stream-json protocol.
class ClaudeCodeSession : AgentSession
{
@@ -759,6 +850,12 @@ class ClaudeCodeSession : AgentSession
else
args = claudeArgs;
+ // a workspace set to auto-allow every tool call is already trusted; tell
+ // Claude so, since in -p mode it otherwise treats the dir as untrusted and
+ // drops its settings.local.json allow-list and hooks. keyed per-directory.
+ if (config.permissionPolicy == "allow" && config.workDir.length > 0)
+ ensureProjectTrusted(config.workDir, claudeJsonPath());
+
process = new AgentProcess(args, logName: "claude");
process.onStdoutLine = (string line) {