Compare commits

..
Author SHA1 Message Date
shadowdaoandClaude Opus 5.5 08cd05000c fix(ci): verify the binary inside the installers, not the loose target copy
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / test (pull_request) Successful in 4m42s
Build App (Preview) / build-macos (pull_request) Successful in 3m9s
Build App (Preview) / build-windows (pull_request) Successful in 5m7s
Build App (Preview) / build-linux (pull_request) Successful in 10m24s
Build App (Preview) / prune-previews (pull_request) Successful in 7s
The first fully signed run (signer CN=OLS Digital LLC) signed the app
binary, the MSI, the NSIS installer and the uninstaller, and skipped the
seven build-time DLLs. It still failed verification on
target\release\triple-c.exe. Tauri patches that file with bundle-type
information before each bundle, signs it, packages it, and patches it
again, so the loose copy is unsigned by design and is not what ships.

Verification now:
- checks the installers themselves;
- unpacks the MSI with `msiexec /a` and checks the binaries inside it;
- for NSIS, which can't be unpacked that way, requires the signing log to
  show that the app binary and the uninstaller were signed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 18:53:58 -07:00
shadowdaoandClaude Opus 5.5 947bb8e020 docs: Windows signing is release-only, allowlisted, and gated by branch protection
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / test (pull_request) Successful in 4m4s
Build App (Preview) / build-macos (pull_request) Successful in 3m5s
Build App (Preview) / build-windows (pull_request) Successful in 5m35s
Build App (Preview) / build-linux (pull_request) Successful in 10m8s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 18:35:12 -07:00
shadowdaoandClaude Opus 5.5 a9432324a7 ci: sign only what ships, stop signing previews, log signing output
Secret Scan / scan (push) Successful in 5s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / test (pull_request) Successful in 5m25s
Build App (Preview) / build-macos (pull_request) Successful in 4m15s
Build App (Preview) / build-windows (pull_request) Successful in 6m11s
Build App (Preview) / build-linux (pull_request) Successful in 14m11s
Build App (Preview) / prune-previews (pull_request) Successful in 5s
Artifact Signing is metered at about 1000 signatures a month, and there
were 67 Windows builds last month.

- windows-sign.ps1 signs only what reaches users: the app binary, the MSI,
  the NSIS installer and the uninstaller. It skips the WiX extension DLLs
  and NSIS plugins that Tauri also offers, and any file that is already
  validly signed. That is 4 signatures per release.
- Previews are no longer signed, so the preview workflow no longer
  references the signing secrets. A PR's workflow runs the PR's own code.
  The inline TAURI_CONFIG there, which the v2 CLI never read, becomes a
  real `--config`.
- Tauri reports a failed sign command only as "failed to run powershell".
  The script now keeps a transcript, signtool /debug included, and the
  release job prints it on failure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 18:16:32 -07:00
shadowdaoandClaude Opus 5.5 cc274f39a8 fix(ci): pass the sign command with --config and prove the uninstaller was signed
Secret Scan / scan (push) Successful in 8s
Build App (Preview) / compute-version (pull_request) Successful in 12s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m48s
Build App (Preview) / test (pull_request) Successful in 3m41s
Build App (Preview) / build-linux (pull_request) Successful in 7m8s
Build App (Preview) / build-windows (pull_request) Failing after 8m53s
Build App (Preview) / prune-previews (pull_request) Skipped
The first signing run built nothing signed, and "Verify signatures" failed
it as intended. The Tauri 2 CLI never reads TAURI_CONFIG. It only sets that
variable for tauri-build, so the sign command was dropped silently, just as
the inline beforeBuildCommand override had been for as long as the Windows
jobs have set it. The setup now writes a config file, and the build passes
it with `cargo tauri build --config`.

Review follow-ups:
- The NSIS uninstaller is written to %TEMP% and signed from 32-bit makensis.
  SYSTEM's %TEMP% sits under System32, which WOW64 redirects for makensis but
  not for the x64 signtool, so they would disagree about where the file is.
  %TEMP% and %TMP% now point into the workspace. makensis ignores the sign
  command's exit code for the uninstaller, so windows-sign.ps1 logs every
  file it signs, and the verify step requires a logged signature under that
  temp directory.
- The signing client is pinned by SHA-512, so the pin can be checked against
  nuget.org's published packageHash.
- tauri-cli on Windows is pinned to =2.11.0 --locked, the @tauri-apps/cli
  version the Linux and macOS jobs run from the lockfile, instead of "^2".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 18:05:03 -07:00
shadowdaoandClaude Opus 5.5 44e9bd2916 ci: sign Windows builds with Azure Artifact Signing
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 4s
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 3m19s
Build App (Preview) / test (pull_request) Successful in 4m59s
Build App (Preview) / build-linux (pull_request) Successful in 5m18s
Build App (Preview) / build-windows (pull_request) Failing after 5m32s
Build App (Preview) / prune-previews (pull_request) Skipped
Releases and PR previews now sign the app binary, the MSI, the NSIS
installer and its uninstaller. "Verify signatures" fails the job on any
unsigned or untimestamped .exe/.msi, so an unsigned installer can't ship
quietly.

- windows-signing-setup.ps1 fetches Microsoft.ArtifactSigning.Client 1.0.128
  and a job-local .NET 10.0.12 runtime, each pinned by hash. Nothing is
  installed on the build VM. It also writes the dlib metadata and exports
  TAURI_CONFIG with bundle.windows.signCommand.
- windows-sign.ps1 runs the installed signtool with /dlib, SHA-256 and the
  Microsoft timestamp server, with retries. Credentials come only from the
  AZURE_* environment. The metadata excludes every credential type except
  EnvironmentCredential, because InteractiveBrowserCredential would hang a
  job running as SYSTEM.
- The signing files go in the workspace, not %TEMP%, because the uninstaller
  is signed from 32-bit makensis and WOW64 redirects SYSTEM's %TEMP%.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 17:57:32 -07:00
12 changed files with 17 additions and 74 deletions
+4 -12
View File
@@ -469,7 +469,6 @@ replaces the old Full Permissions on/off switch.
| **Plan** | Proposes a plan and makes no changes | `--permission-mode plan` |
| **Default** | Asks before each tool call | *(nothing — Claude Code's own default)* |
| **Accept Edits** | Auto-approves file edits; other tools still prompt | `--permission-mode acceptEdits` |
| **Auto** | A safety classifier approves routine actions and blocks risky ones, without prompting | `--permission-mode auto` |
| **Bypass** | Auto-approves every tool call | `--dangerously-skip-permissions` |
New projects start in **Default**. Projects created before permission modes existed keep behaving
@@ -481,17 +480,12 @@ the way they did: one that had Full Permissions on becomes **Bypass**, one that
> has Docker socket access or reaches services on your network. The Overview tab tells you whether
> the in-container sandbox is also on.
**Auto** sits between Accept Edits and Bypass: Claude Code's own classifier reviews each action,
lets routine work through and blocks things that look risky (such as destructive or
exfiltrating commands) — no prompts either way. Whether it is available depends on your Claude
Code account and model; see Claude Code's documentation on permission modes.
### When a change takes effect
- **Terminals** — the mode is applied when a terminal is opened, so it affects terminals you open
from then on. A Claude session that is already running keeps the permissions it started with;
close the tab and open a new terminal to change it. The badge on each terminal tab shows the mode
that terminal was launched with (`plan`, `ask`, `edits`, `auto`, `bypass`).
that terminal was launched with (`plan`, `ask`, `edits`, `bypass`).
- **Resumed sessions** — a session resumed from the **Sessions** tab uses the project's current
mode.
- **Scheduled tasks** — these now honour the permission mode too (they previously always ran with
@@ -500,9 +494,8 @@ Code account and model; see Claude Code's documentation on permission modes.
mode change to reach the scheduler.
> Scheduled tasks run headless (`claude -p`) and cannot answer a permission prompt. In any mode
> other than **Auto** or **Bypass**, a task may simply stop early when Claude Code asks for
> approval. In **Auto**, actions the classifier blocks are denied and the run carries on without
> them. Its run log records which mode it used.
> other than **Bypass**, a task may simply stop early when Claude Code asks for approval. Its run
> log records which mode it used.
---
@@ -1356,8 +1349,7 @@ Scheduled runs use the project's [permission mode](#permission-modes) — they n
with `--dangerously-skip-permissions`. Because the mode travels into the container as an
environment variable, **stop and start the project** after changing it for the scheduler to see the
change. Remember that a headless run cannot answer a permission prompt, so in any mode other than
**Auto** or **Bypass** a task may stop early when Claude Code asks for approval (in Auto, blocked
actions are denied instead); the run log records the mode
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
that was used.
### Creating Tasks
+2 -3
View File
@@ -114,7 +114,7 @@ progress modal.
## Permission Modes
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Five states,
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
mapped to CLI flags by `PermissionMode::cli_args()`:
| Mode | Serialized | CLI args passed to `claude` |
@@ -122,7 +122,6 @@ mapped to CLI flags by `PermissionMode::cli_args()`:
| **Plan** | `plan` | `--permission-mode plan` |
| **Default** | `default` | *(none)* |
| **Accept Edits** | `acceptEdits` | `--permission-mode acceptEdits` |
| **Auto** | `auto` | `--permission-mode auto` |
| **Bypass** | `bypass` | `--dangerously-skip-permissions` |
`Project.permission_mode` is `Option<PermissionMode>`; `effective_permission_mode()` falls back to
@@ -532,7 +531,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic |
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Auto / Bypass segmented control |
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9`, `Ctrl+Shift+←/→` |
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
+2 -3
View File
@@ -186,7 +186,7 @@ host keychain secrets.
### Permission Modes
`PermissionMode` (`models/project.rs`) is a five-state enum replacing the earlier `full_permissions`
`PermissionMode` (`models/project.rs`) is a four-state enum replacing the earlier `full_permissions`
boolean. It reaches Claude Code by two different routes:
| Mode | `cli_args()` — interactive terminals | `as_env_value()` — scheduler |
@@ -194,7 +194,6 @@ boolean. It reaches Claude Code by two different routes:
| `Plan` | `--permission-mode plan` | `plan` |
| `Default` | *(no flag)* | `default` |
| `AcceptEdits` | `--permission-mode acceptEdits` | `acceptEdits` |
| `Auto` | `--permission-mode auto` | `auto` |
| `Bypass` | `--dangerously-skip-permissions` | `bypass` |
`Project.permission_mode` is `Option<PermissionMode>`, and `effective_permission_mode()` resolves
@@ -475,7 +474,7 @@ triple-c/
│ │ ├── ProjectRow.tsx # Select-only sidebar row
│ │ ├── ProjectList.tsx # Sidebar project list
│ │ ├── AddProjectDialog.tsx # New-project dialog
│ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Auto/Bypass
│ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Bypass
│ │ ├── ConfirmRemoveModal.tsx # Project removal confirmation
│ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings,
│ │ # ClaudeInstructions, ClaudeCodeSettings —
@@ -469,20 +469,6 @@ mod tests {
assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]);
}
/// Auto mode is passed as a `--permission-mode` value, not its own flag.
#[test]
fn build_terminal_cmd_passes_auto_permission_mode() {
let mut p = project("anthropic", serde_json::Value::Null);
p.permission_mode = Some(crate::models::project::PermissionMode::Auto);
let cmd = build_claude_terminal_cmd(&p, None, None);
assert!(
cmd[2].contains("exec claude '--permission-mode' 'auto'"),
"got: {}",
cmd[2]
);
}
/// The Bedrock-profile path keeps its AWS validation *and* gains the
/// prelude, immediately before the exec.
#[test]
-5
View File
@@ -166,9 +166,6 @@ pub enum PermissionMode {
Default,
/// Auto-accept file edits, prompt for everything else.
AcceptEdits,
/// Claude Code's classifier approves safe actions and blocks risky ones,
/// without prompting.
Auto,
/// Skip all permission prompts.
Bypass,
}
@@ -183,7 +180,6 @@ impl PermissionMode {
PermissionMode::AcceptEdits => {
vec!["--permission-mode".to_string(), "acceptEdits".to_string()]
}
PermissionMode::Auto => vec!["--permission-mode".to_string(), "auto".to_string()],
PermissionMode::Bypass => vec!["--dangerously-skip-permissions".to_string()],
}
}
@@ -195,7 +191,6 @@ impl PermissionMode {
PermissionMode::Plan => "plan",
PermissionMode::Default => "default",
PermissionMode::AcceptEdits => "acceptEdits",
PermissionMode::Auto => "auto",
PermissionMode::Bypass => "bypass",
}
}
-1
View File
@@ -26,7 +26,6 @@ const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> =
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" },
auto: { text: "auto", className: "bg-[var(--accent-muted)] text-[var(--accent)]" },
bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" },
};
@@ -75,11 +75,11 @@ describe("PermissionModeControl", () => {
vi.clearAllMocks();
});
it("renders all five modes as a radio group with the effective one checked", () => {
it("renders all four modes as a radio group with the effective one checked", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
const group = screen.getByRole("radiogroup", { name: "Permission mode" });
expect(group).toBeInTheDocument();
expect(screen.getAllByRole("radio")).toHaveLength(5);
expect(screen.getAllByRole("radio")).toHaveLength(4);
expect(screen.getByRole("radio", { name: "Default" })).toHaveAttribute(
"aria-checked",
"true",
@@ -92,14 +92,6 @@ describe("PermissionModeControl", () => {
expect(onChange).toHaveBeenCalledWith("acceptEdits");
});
it("offers Auto between Accept Edits and Bypass", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
const labels = screen.getAllByRole("radio").map((r) => r.textContent);
expect(labels).toEqual(["Plan", "Default", "Accept Edits", "Auto", "Bypass"]);
fireEvent.click(screen.getByRole("radio", { name: "Auto" }));
expect(onChange).toHaveBeenCalledWith("auto");
});
it("moves selection with the arrow keys", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
fireEvent.keyDown(screen.getByRole("radiogroup", { name: "Permission mode" }), {
@@ -9,11 +9,6 @@ export const PERMISSION_MODES: Segment<PermissionMode>[] = [
label: "Accept Edits",
hint: "File edits are auto-approved; other tools still prompt.",
},
{
value: "auto",
label: "Auto",
hint: "A safety classifier approves routine actions and blocks risky ones, without prompting.",
},
{
value: "bypass",
label: "Bypass",
@@ -159,18 +159,12 @@ describe("TaskEditorModal", () => {
});
it("warns that a headless run cannot answer a permission prompt", async () => {
// Bypass (and Auto, below) are the modes where an unattended run cannot stall.
// Bypass is the only mode where an unattended run is safe from stalling.
await renderEditor(null, { ...baseProject, permission_mode: "bypass" });
expect(screen.getByText(/headless/i)).toBeInTheDocument();
expect(screen.queryByText(/cannot answer a permission prompt/i)).toBeNull();
});
it("tells Auto mode that blocked actions are denied, not prompted", async () => {
await renderEditor(null, { ...baseProject, permission_mode: "auto" });
expect(screen.queryByText(/cannot answer a permission prompt/i)).toBeNull();
expect(screen.getByText(/blocks are denied/i)).toBeInTheDocument();
});
it("spells out the stall risk in any non-Bypass mode", async () => {
await renderEditor(null, { ...baseProject, permission_mode: "default" });
expect(screen.getByText(/cannot answer a permission prompt/i)).toBeInTheDocument();
@@ -301,17 +301,11 @@ export default function TaskEditorModal({ project, task, onClose, onSaved }: Pro
terminal attached, using this project&rsquo;s permission mode (
<strong className="text-[var(--text-primary)]">{modeLabel}</strong>).
</p>
{mode === "auto" && (
<p className="text-xs text-[var(--text-secondary)]">
In Auto mode nothing prompts: actions the safety classifier blocks are denied and the
run carries on without them, so check the log if a task seems to have skipped a step.
</p>
)}
{mode !== "bypass" && mode !== "auto" && (
{mode !== "bypass" && (
<p className="text-xs text-[var(--warning)]">
A headless run cannot answer a permission prompt. In {modeLabel} mode the task may
stall and produce an empty log; set the mode to Auto or Bypass in the Config tab
for unattended runs.
stall and produce an empty log; set the mode to Bypass in the Config tab for
unattended runs.
</p>
)}
</div>
+1 -1
View File
@@ -126,7 +126,7 @@ export const CUSTOM_ENDPOINT_BACKENDS: readonly Backend[] = [
];
/** Mirrors Rust `PermissionMode` (serde camelCase). */
export type PermissionMode = "plan" | "default" | "acceptEdits" | "auto" | "bypass";
export type PermissionMode = "plan" | "default" | "acceptEdits" | "bypass";
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
+2 -4
View File
@@ -61,14 +61,12 @@ TASK_TYPE=$(jq -r '.type' "$TASK_FILE")
# project's permission setting. Keep this mapping in sync with
# PermissionMode::cli_args() in app/src-tauri/src/models/project.rs.
# NOTE: headless `claude -p` runs cannot answer a permission prompt, so any
# mode other than "bypass" or "auto" means the task may stop early when Claude
# Code asks for permission. In "auto", actions the classifier blocks are
# denied rather than prompted, so the run continues without them. Unset or unrecognized values pass no flag (Claude's default).
# mode other than "bypass" means the task may stop early when Claude Code asks
# for permission. Unset or unrecognized values pass no flag (Claude's default).
PERMISSION_ARGS=()
case "${TRIPLE_C_PERMISSION_MODE:-}" in
plan) PERMISSION_ARGS=(--permission-mode plan) ;;
acceptEdits) PERMISSION_ARGS=(--permission-mode acceptEdits) ;;
auto) PERMISSION_ARGS=(--permission-mode auto) ;;
bypass) PERMISSION_ARGS=(--dangerously-skip-permissions) ;;
*) PERMISSION_ARGS=() ;;
esac