mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
feat(task-chat): show hook and auto-review decisions as governance rows (#19266)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run on harnesses such as Codex and Claude, and the task chat shows what each run did > - Harnesses make automatic decisions: hooks block or allow tool calls, and Codex auto-review approves or denies actions > - Today Paperclip drops these decisions or shows them as generic rows. Codex `safety.review.*` always says `decision: "unknown"`, the native Rust runner drops `hook/*` and `item/autoApprovalReview/*`, and claude-agent-acp drops Claude hook messages > - An operator cannot see why an agent changed course or which action ran without a person's approval. This conflicts with Paperclip's approval-gate model > - This pull request records each hook and review decision with its reason and shows it as a small governance row in the task chat > - The benefit is that automatic decisions become auditable, and a blocked action explains itself without opening run logs ## Linked Issues or Issue Description Refs #19252 (precedent: canonical `plan.updated` events rendered as a task-chat card). Builds on #19259 (ACP `_meta` passthrough from ACPX), now merged. **Subsystem affected** Runner provider events (Rust native Codex normalizer, TS drivers, ACPX sidecar), the claude-agent-acp patch, the PRP provider-event schema, and the task chat activity UI. **Problem or motivation** Hook and auto-review outcomes reach Paperclip but are lost or flattened. Codex reviews always record an unknown decision. Native Codex runs drop hook and review notifications in the Rust normalizer. Claude hook messages never leave the adapter. Codex reuses one `run.id` for every call of a hook, and the id contains a host path. **Proposed solution** Map the real outcome, decision, action, risk and reason into additive optional fields on `paperclip.hook.v1` and `paperclip.safety.review.v1`. Forward Claude SDK hook messages. Render small grey rows: a stopped action is tinted and shows its reason, an approved review folds into the command it approved as an "auto-approved" mark, and routine hook passes stay out of activity summaries. **Alternatives considered** Separate rows for every approval. This duplicated each approved command, so approvals fold into the command row instead. ## What Changed - Runner (Rust): new `governance_events.rs`. `normalize_codex_notification` now maps `hook/started|completed` and `item/autoApprovalReview/started|completed`. Each hook call gets its own id (a digest of the run id and start time), so no host path is stored. - Runner (TS): new `governance-events.ts` with the same mapping for the TS Codex driver, and the Claude hook mapping. The ACPX sidecar publishes Claude hook updates as schema-validated `hook.*` rich events. - claude-agent-acp patch: turns on `includeHookEvents` and forwards `hook_started` / `hook_response` on `session_info_update._meta.claudeCode.hook`, with bounded output. - Protocol: additive optional fields. Hook records get `outcome`, `message`, `source` and `toolName`, and the `blocked` and `stopped` statuses. Review records get `actionKind`, `action`, `riskLevel`, `userConsent` (the reviewer's user-authorization level; keys that end in "authorization" are redacted as secrets), `rationale` and `decidedBy`, and the `timed_out` and `aborted` decisions. Regenerated the manifest and validators. - Fixed: the hook `blocking` flag read `executionMode === "blocking"`, but Codex sends `sync` / `async`. - UI: `native-run-events` keeps the full hook and review payloads. `governance-presentation.ts` writes the row labels. `fold-auto-reviews.ts` attaches an approved review to its command row. The rows show the tint, the reason line and the "auto-approved" mark. Activity summaries lead with a block or denial. - Storybook: "Task chat / Governance rows" shows every outcome, a Codex run, a Claude run and the live line. - Docs: added a governance activity section to `packages/paperclip-runner/docs/protocol-compatibility.md`. ## Verification - `pnpm --filter @paperclipai/paperclip-runner exec vitest run src/governance-events.test.ts src/provider-events.test.ts src/drivers/codex src/drivers/acpx src/cli`. 2305 tests pass. This includes a replay of real Codex 0.160 notifications from a session with hooks and auto-review (`test-fixtures/governance/codex-0.160-hooks-auto-review.json`, sanitized). - `cargo test --release -p paperclip-runner-core`. The full suite passes, and `governance_events::tests` replays the same fixture. - `cd ui && npx vitest run`. 11,047 tests pass. - `node scripts/typecheck-low-memory.mjs`, `pnpm check:token-gates`, and the runner contract checks (`check:protocol-manifest`, `check:protocol-types`, `check:capability-contract`, `check:protocol-coverage`, replay goldens, contract fingerprints) pass. - Live test on a local isolated instance. A Codex agent and a Claude agent each used a preconfigured home with a PreToolUse hook that blocks edits to `0001_init.sql`, plus UserPromptSubmit and PostToolUse hooks. Both runs showed "PreToolUse hook blocked a tool call" (Codex) or "PreToolUse hook blocked Bash" (Claude), with the hook's message. They also showed "UserPromptSubmit hook added context", and the group summary started with the block. - Auto-review cannot run in Paperclip today, because the native Codex runner only allows `approvalPolicy: never`. The review mapping and the approval fold are proven against real Codex 0.160 app-server output (fixture above) and in Storybook. ## Risks - Low risk to execution. These records are display-only and carry no authority. All new schema fields are optional, so older records stay valid and older UIs ignore the new fields. - The `auto-approved` fold depends on `targetItemId` matching the command's execution id. Real Codex 0.160 data confirms that it matches. When there is no match, the review stays as its own row. - In Paperclip's isolated homes, hooks do not run, because the runner never copies hooks into an isolated home. Rows appear only for agents with a preconfigured home, or with future managed hooks. - The claude-agent-acp patch changes. The ACPX profile check still passes, because the patch is Paperclip's ACPX layer and not the upstream release. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, browser and file editing) and extended reasoning. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b86f55f0ba
commit
2575b941d0
31 files changed
+3068
-68
No files matched your search
@@ -74,6 +74,36 @@ Codex `turn/diff/updated` is normalized separately as the latest same-turn
|
||||
families can drive a turn-status UI without changing the public PRP family or
|
||||
adding a control-plane endpoint.
|
||||
|
||||
## Governance activity: hooks and automatic approval reviews
|
||||
|
||||
`hook.started` / `hook.completed` (`paperclip.hook.v1`) and
|
||||
`safety.review.started` / `safety.review.completed`
|
||||
(`paperclip.safety.review.v1`) are display records. They make decisions that
|
||||
no person clicked auditable. They never grant or deny execution authority.
|
||||
|
||||
- A hook record has an `outcome`: `running`, `passed`, `context`, `feedback`,
|
||||
`blocked`, `stopped` or `failed`. `message` holds the hook's own text for
|
||||
that outcome. `source` names where the hook is configured (`user hooks`,
|
||||
`project hooks`) or the script file name, never an absolute host path.
|
||||
`toolName` is set only when the harness reports it.
|
||||
- A review record has a `decision`: `pending`, `allowed`, `denied`,
|
||||
`user_required`, `timed_out`, `aborted` or `unknown`. `action` is a short
|
||||
description of the reviewed action. Terminal input is never echoed.
|
||||
`targetExecutionId` matches the `executionId` of the reviewed tool
|
||||
execution when the harness reports one.
|
||||
- All fields after `summary` are optional. A record without them is a
|
||||
valid v1 record.
|
||||
|
||||
| Qualified adapter profile | Hooks | Approval reviews |
|
||||
|---|---|---|
|
||||
| Direct Codex App Server | `hook/started`, `hook/completed` | `item/autoApprovalReview/*` (only with an on-request approval policy) |
|
||||
| ACPX Claude | Claude Agent SDK hook messages, forwarded by the patched adapter on `session_info_update._meta.claudeCode.hook` | Unavailable |
|
||||
| Other ACPX profiles | Unavailable | Unavailable |
|
||||
|
||||
Hooks run only when the harness home defines and trusts them. An isolated
|
||||
Paperclip home defines none, so these records appear for agents with a
|
||||
preconfigured home.
|
||||
|
||||
## Provider-neutral semantic receipts
|
||||
|
||||
- `capabilities.semanticTools` advertises stable operation IDs, availability,
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
{
|
||||
"path": "schemas/provider-event.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json",
|
||||
"sha256": "ab37a2a97b35a79bd593edd1e97f874afd751f01bf564dac8847e979a5b81fa2"
|
||||
"sha256": "e8cdfbe17f2c780a6010896d01be8c6dfb9a5f3727d13814fe4f703bb7fcdbc4"
|
||||
},
|
||||
{
|
||||
"path": "schemas/provider-failure.schema.json",
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
"hook": {
|
||||
"type": "object",
|
||||
"required": ["schema", "hookId", "event", "scope", "status", "blocking", "durationMs", "summary"],
|
||||
"properties": { "schema": { "const": "paperclip.hook.v1" }, "hookId": { "$ref": "#/$defs/id" }, "event": { "type": "string", "minLength": 1, "maxLength": 160 }, "scope": { "type": "string", "minLength": 1, "maxLength": 160 }, "status": { "enum": ["running", "completed", "failed", "cancelled"] }, "blocking": { "type": "boolean" }, "durationMs": { "type": ["integer", "null"], "minimum": 0 }, "summary": { "$ref": "#/$defs/nullableShortText" } },
|
||||
"properties": { "schema": { "const": "paperclip.hook.v1" }, "hookId": { "$ref": "#/$defs/id" }, "event": { "type": "string", "minLength": 1, "maxLength": 160 }, "scope": { "type": "string", "minLength": 1, "maxLength": 160 }, "status": { "enum": ["running", "completed", "failed", "cancelled", "blocked", "stopped"] }, "blocking": { "type": "boolean" }, "durationMs": { "type": ["integer", "null"], "minimum": 0 }, "summary": { "$ref": "#/$defs/nullableShortText" }, "outcome": { "enum": ["running", "passed", "context", "feedback", "blocked", "stopped", "failed"] }, "message": { "$ref": "#/$defs/nullableShortText" }, "source": { "type": ["string", "null"], "maxLength": 160 }, "toolName": { "type": ["string", "null"], "maxLength": 160 } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"memoryCitation": {
|
||||
@@ -167,7 +167,7 @@
|
||||
"safetyReview": {
|
||||
"type": "object",
|
||||
"required": ["schema", "reviewId", "targetExecutionId", "status", "decision", "summary"],
|
||||
"properties": { "schema": { "const": "paperclip.safety.review.v1" }, "reviewId": { "$ref": "#/$defs/id" }, "targetExecutionId": { "anyOf": [{ "$ref": "#/$defs/id" }, { "type": "null" }] }, "status": { "enum": ["running", "completed", "failed"] }, "decision": { "enum": ["pending", "allowed", "denied", "user_required", "unknown"] }, "summary": { "$ref": "#/$defs/nullableShortText" } },
|
||||
"properties": { "schema": { "const": "paperclip.safety.review.v1" }, "reviewId": { "$ref": "#/$defs/id" }, "targetExecutionId": { "anyOf": [{ "$ref": "#/$defs/id" }, { "type": "null" }] }, "status": { "enum": ["running", "completed", "failed"] }, "decision": { "enum": ["pending", "allowed", "denied", "user_required", "timed_out", "aborted", "unknown"] }, "summary": { "$ref": "#/$defs/nullableShortText" }, "actionKind": { "enum": ["command", "execve", "writeStdin", "applyPatch", "networkAccess", "mcpToolCall", "requestPermissions", "unknown"] }, "action": { "type": ["string", "null"], "maxLength": 1000 }, "riskLevel": { "enum": ["low", "medium", "high", "critical", null] }, "userConsent": { "enum": ["unknown", "low", "medium", "high", null] }, "rationale": { "$ref": "#/$defs/nullableShortText" }, "decidedBy": { "type": ["string", "null"], "maxLength": 160 } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"terminalInput": {
|
||||
|
||||
@@ -2640,6 +2640,52 @@ fn current_timestamp() -> Result<String, DurableRunnerError> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn governance_display_records_survive_the_durable_writer_unchanged() {
|
||||
use super::*;
|
||||
let review = crate::governance_events::codex_safety_review_payload(
|
||||
"item/autoApprovalReview/completed",
|
||||
&json!({
|
||||
"reviewId": "review-1",
|
||||
"targetItemId": "exec-1",
|
||||
"decisionSource": "agent",
|
||||
"review": {"status": "approved", "riskLevel": "low", "userAuthorization": "high", "rationale": "Generated output."},
|
||||
"action": {"type": "command", "source": "shell", "command": "rm -rf build", "cwd": "/work"},
|
||||
}),
|
||||
);
|
||||
let hook = crate::governance_events::codex_hook_payload(
|
||||
"hook/completed",
|
||||
&json!({"run": {
|
||||
"id": "pre-tool-use:0:/x/config.toml", "eventName": "preToolUse", "executionMode": "sync",
|
||||
"scope": "turn", "sourcePath": "/x/config.toml", "source": "user", "status": "blocked",
|
||||
"startedAt": 1, "durationMs": 41, "entries": [{"kind": "feedback", "text": "Append-only."}],
|
||||
}}),
|
||||
);
|
||||
for (event_type, payload) in [
|
||||
("safety.review.completed", review),
|
||||
("hook.completed", hook),
|
||||
] {
|
||||
// Native Codex records also carry the provider label.
|
||||
let mut labelled = payload.clone();
|
||||
labelled["provider"] = json!("codex");
|
||||
for candidate in [payload, labelled] {
|
||||
let preserved = preserve_bounded_display_content(
|
||||
event_type,
|
||||
&candidate,
|
||||
sanitize_value(&candidate),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{event_type} was rejected: {error:?}"));
|
||||
assert_eq!(
|
||||
preserved, candidate,
|
||||
"{event_type} changed in the durable writer"
|
||||
);
|
||||
assert!(!durable_semantics_changed_by_sanitization(
|
||||
&candidate, &preserved
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_delta_redaction_buffers_every_split_and_never_serializes_pending_material() {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
//! Canonical payloads for governance activity from the native Codex harness:
|
||||
//! hooks (`hook/started|completed`) and approval auto-review
|
||||
//! (`item/autoApprovalReview/started|completed`). These are display records
|
||||
//! that keep automatic decisions auditable; they carry no execution authority.
|
||||
//!
|
||||
//! Shapes are the `hook` and `safetyReview` definitions in
|
||||
//! `protocol/schemas/provider-event.schema.json`, and mirror
|
||||
//! `src/governance-events.ts` used by the TypeScript drivers.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::durable::redact_text;
|
||||
|
||||
const MESSAGE_CHARS: usize = 4_000;
|
||||
const ACTION_CHARS: usize = 1_000;
|
||||
const NAME_CHARS: usize = 160;
|
||||
|
||||
fn text(value: Option<&Value>) -> &str {
|
||||
value.and_then(Value::as_str).unwrap_or("")
|
||||
}
|
||||
|
||||
fn clip(value: &str, limit: usize) -> String {
|
||||
let redacted = redact_text(value);
|
||||
if redacted.chars().count() <= limit {
|
||||
return redacted;
|
||||
}
|
||||
let mut clipped: String = redacted.chars().take(limit.saturating_sub(1)).collect();
|
||||
clipped.push('…');
|
||||
clipped
|
||||
}
|
||||
|
||||
fn nullable_clip(value: Option<&Value>, limit: usize) -> Value {
|
||||
let trimmed = text(value).trim();
|
||||
if trimmed.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
Value::String(clip(trimmed, limit))
|
||||
}
|
||||
}
|
||||
|
||||
fn non_negative_integer(value: Option<&Value>) -> Option<u64> {
|
||||
match value? {
|
||||
Value::Number(number) => number.as_u64(),
|
||||
Value::String(raw) if !raw.is_empty() && raw.bytes().all(|byte| byte.is_ascii_digit()) => {
|
||||
raw.parse().ok()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_id(value: &str, fallback: &str) -> String {
|
||||
let candidate: String = value
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric() || "._:-".contains(character) {
|
||||
character
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.take(NAME_CHARS)
|
||||
.collect();
|
||||
if candidate
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|character| character.is_ascii_alphanumeric())
|
||||
{
|
||||
candidate
|
||||
} else {
|
||||
fallback.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex reuses `run.id` (`<event>:<order>:<config path>`) for every call of
|
||||
/// the same configured hook, and it embeds a host path. One invocation is a
|
||||
/// digest of that id plus the start time that started and completed share.
|
||||
fn codex_hook_run_id(run: &Value, turn_id: &str) -> String {
|
||||
let definition = match text(run.get("id")) {
|
||||
"" => format!(
|
||||
"{}:{}",
|
||||
text(run.get("eventName")),
|
||||
run.get("displayOrder")
|
||||
.map(Value::to_string)
|
||||
.unwrap_or_default()
|
||||
),
|
||||
id => id.to_owned(),
|
||||
};
|
||||
let digest = Sha256::digest(format!("{turn_id}|{definition}").as_bytes());
|
||||
let short: String = digest
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
match non_negative_integer(run.get("startedAt")) {
|
||||
Some(started_at) => format!("hook-{short}-{started_at}"),
|
||||
None => format!("hook-{short}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the hook is defined, without the host path. Codex reports the file
|
||||
/// that declares the hook (usually `config.toml`), so a config file reads as
|
||||
/// its layer ("user hooks") rather than a filename.
|
||||
fn hook_source(path: Option<&Value>, layer: Option<&Value>) -> Value {
|
||||
let name = text(path)
|
||||
.trim()
|
||||
.rsplit(['/', '\\'])
|
||||
.find(|segment| !segment.is_empty())
|
||||
.unwrap_or("");
|
||||
let config_file = matches!(
|
||||
name,
|
||||
"config.toml" | "hooks.json" | "requirements.toml" | "managed_config.toml"
|
||||
);
|
||||
if !name.is_empty() && !config_file {
|
||||
return Value::String(clip(name, NAME_CHARS));
|
||||
}
|
||||
match text(layer) {
|
||||
source @ ("user" | "project" | "system" | "plugin") => {
|
||||
Value::String(format!("{source} hooks"))
|
||||
}
|
||||
"" | "unknown" if !name.is_empty() => Value::String(clip(name, NAME_CHARS)),
|
||||
"" | "unknown" => Value::Null,
|
||||
_ => Value::String("managed hooks".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn first_entry<'a>(entries: &'a [(String, String)], kinds: &[&str]) -> Option<&'a str> {
|
||||
kinds.iter().find_map(|kind| {
|
||||
entries
|
||||
.iter()
|
||||
.find(|(entry_kind, _)| entry_kind == kind)
|
||||
.map(|(_, entry_text)| entry_text.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
/// Codex `hook/started` and `hook/completed` (`HookRunSummary` in `params.run`).
|
||||
pub(crate) fn codex_hook_payload(method: &str, params: &Value) -> Value {
|
||||
let run = params.get("run").unwrap_or(&Value::Null);
|
||||
let started = method == "hook/started";
|
||||
let raw_status = if started {
|
||||
"running"
|
||||
} else {
|
||||
match text(run.get("status")) {
|
||||
"" => "completed",
|
||||
status => status,
|
||||
}
|
||||
};
|
||||
let status = match raw_status {
|
||||
"running" | "failed" | "blocked" | "stopped" | "cancelled" => raw_status,
|
||||
_ => "completed",
|
||||
};
|
||||
let entries: Vec<(String, String)> = run
|
||||
.get("entries")
|
||||
.and_then(Value::as_array)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.take(64)
|
||||
.map(|entry| {
|
||||
(
|
||||
text(entry.get("kind")).to_owned(),
|
||||
text(entry.get("text")).trim().to_owned(),
|
||||
)
|
||||
})
|
||||
.filter(|(_, entry_text)| !entry_text.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let has = |kind: &str| entries.iter().any(|(entry_kind, _)| entry_kind == kind);
|
||||
let outcome = match status {
|
||||
"running" => "running",
|
||||
"blocked" | "stopped" | "failed" => status,
|
||||
"cancelled" => "failed",
|
||||
_ if has("feedback") => "feedback",
|
||||
_ if has("context") => "context",
|
||||
_ => "passed",
|
||||
};
|
||||
let message = match outcome {
|
||||
"blocked" | "stopped" => first_entry(
|
||||
&entries,
|
||||
&["stop", "feedback", "error", "warning", "context"],
|
||||
),
|
||||
"failed" => first_entry(&entries, &["error", "warning", "stop", "feedback"]),
|
||||
"feedback" => first_entry(&entries, &["feedback"]),
|
||||
"context" => first_entry(&entries, &["context"]),
|
||||
_ => first_entry(&entries, &["warning"]),
|
||||
};
|
||||
json!({
|
||||
"schema": "paperclip.hook.v1",
|
||||
"hookId": codex_hook_run_id(run, text(params.get("turnId"))),
|
||||
"event": clip(match text(run.get("eventName")) { "" => "unknown", name => name }, NAME_CHARS),
|
||||
"scope": clip(match text(run.get("scope")) { "" => "provider", scope => scope }, NAME_CHARS),
|
||||
"status": status,
|
||||
// Codex reports `sync` hooks, which can block the action, and `async` ones.
|
||||
"blocking": matches!(text(run.get("executionMode")), "sync" | "blocking"),
|
||||
"durationMs": non_negative_integer(run.get("durationMs")),
|
||||
"summary": nullable_clip(run.get("statusMessage"), MESSAGE_CHARS),
|
||||
"outcome": outcome,
|
||||
"message": match message {
|
||||
Some(message) => Value::String(clip(message, MESSAGE_CHARS)),
|
||||
None => match nullable_clip(run.get("statusMessage"), MESSAGE_CHARS) {
|
||||
Value::Null if status == "cancelled" => Value::String("The hook was cancelled.".to_owned()),
|
||||
value => value,
|
||||
},
|
||||
},
|
||||
"source": hook_source(run.get("sourcePath"), run.get("source")),
|
||||
"toolName": Value::Null,
|
||||
})
|
||||
}
|
||||
|
||||
/// `/bin/zsh -lc 'rm -rf build'` reads as `rm -rf build`.
|
||||
fn unwrap_shell_command(command: &str) -> String {
|
||||
let trimmed = command.trim();
|
||||
for shell in [
|
||||
"/bin/zsh",
|
||||
"/bin/bash",
|
||||
"/bin/sh",
|
||||
"/usr/bin/zsh",
|
||||
"/usr/bin/bash",
|
||||
"zsh",
|
||||
"bash",
|
||||
"sh",
|
||||
] {
|
||||
for flag in [" -lc '", " -c '"] {
|
||||
let prefix = format!("{shell}{flag}");
|
||||
if let Some(body) = trimmed
|
||||
.strip_prefix(&prefix)
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
{
|
||||
let unescaped = body.replace("'\\''", "'");
|
||||
if !unescaped.contains('\'') || body.contains("'\\''") {
|
||||
return unescaped;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
command.to_owned()
|
||||
}
|
||||
|
||||
/// A short, display-safe description of what the reviewer was asked to approve.
|
||||
fn safety_action(action: &Value) -> (&'static str, Value) {
|
||||
let kind = match text(action.get("type")) {
|
||||
"command" => "command",
|
||||
"execve" => "execve",
|
||||
"writeStdin" => "writeStdin",
|
||||
"applyPatch" => "applyPatch",
|
||||
"networkAccess" => "networkAccess",
|
||||
"mcpToolCall" => "mcpToolCall",
|
||||
"requestPermissions" => "requestPermissions",
|
||||
_ => "unknown",
|
||||
};
|
||||
let strings = |key: &str| -> Vec<&str> {
|
||||
action
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|values| values.iter().filter_map(Value::as_str).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let summary = match kind {
|
||||
"command" => unwrap_shell_command(text(action.get("command"))),
|
||||
"execve" => {
|
||||
let argv = strings("argv");
|
||||
if argv.is_empty() {
|
||||
text(action.get("program")).to_owned()
|
||||
} else {
|
||||
argv.join(" ")
|
||||
}
|
||||
}
|
||||
// Never echo the input itself; it can be a password typed into a prompt.
|
||||
"writeStdin" => "input to a running command".to_owned(),
|
||||
"applyPatch" => {
|
||||
let files = strings("files");
|
||||
match files.len() {
|
||||
1 => format!("edit {}", files[0]),
|
||||
0 => "edit 0 files".to_owned(),
|
||||
count => format!(
|
||||
"edit {count} files: {}{}",
|
||||
files.iter().take(3).copied().collect::<Vec<_>>().join(", "),
|
||||
if count > 3 { ", …" } else { "" }
|
||||
),
|
||||
}
|
||||
}
|
||||
"networkAccess" => match (
|
||||
text(action.get("host")),
|
||||
non_negative_integer(action.get("port")),
|
||||
) {
|
||||
("", _) => text(action.get("target")).to_owned(),
|
||||
(host, Some(port)) => format!("{host}:{port}"),
|
||||
(host, None) => host.to_owned(),
|
||||
},
|
||||
"mcpToolCall" => {
|
||||
let server = match text(action.get("connectorName")) {
|
||||
"" => text(action.get("server")),
|
||||
name => name,
|
||||
};
|
||||
let tool = match text(action.get("toolTitle")) {
|
||||
"" => text(action.get("toolName")),
|
||||
title => title,
|
||||
};
|
||||
[server, tool]
|
||||
.iter()
|
||||
.filter(|part| !part.is_empty())
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ")
|
||||
}
|
||||
"requestPermissions" => match text(action.get("reason")) {
|
||||
"" => "additional permissions".to_owned(),
|
||||
reason => reason.to_owned(),
|
||||
},
|
||||
_ => String::new(),
|
||||
};
|
||||
let collapsed = summary.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
(
|
||||
kind,
|
||||
if collapsed.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
Value::String(clip(&collapsed, ACTION_CHARS))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Codex `item/autoApprovalReview/started|completed` (approval auto-review).
|
||||
pub(crate) fn codex_safety_review_payload(method: &str, params: &Value) -> Value {
|
||||
let review = params.get("review").unwrap_or(&Value::Null);
|
||||
let started = method.ends_with("started");
|
||||
let decision = if started {
|
||||
"pending"
|
||||
} else {
|
||||
match text(review.get("status")) {
|
||||
"approved" => "allowed",
|
||||
"denied" => "denied",
|
||||
"timedOut" => "timed_out",
|
||||
"aborted" => "aborted",
|
||||
"inProgress" => "pending",
|
||||
_ => "unknown",
|
||||
}
|
||||
};
|
||||
let (action_kind, action) = safety_action(params.get("action").unwrap_or(&Value::Null));
|
||||
let risk_level = match text(review.get("riskLevel")) {
|
||||
level @ ("low" | "medium" | "high" | "critical") => Value::String(level.to_owned()),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let user_authorization = match text(review.get("userAuthorization")) {
|
||||
level @ ("unknown" | "low" | "medium" | "high") => Value::String(level.to_owned()),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let target = text(params.get("targetItemId"));
|
||||
json!({
|
||||
"schema": "paperclip.safety.review.v1",
|
||||
"reviewId": safe_id(text(params.get("reviewId")), "safety-review"),
|
||||
// The native tool execution uses the same stable id for this item.
|
||||
"targetExecutionId": if target.is_empty() { Value::Null } else { Value::String(safe_id(target, "execution")) },
|
||||
"status": if started || decision == "pending" { "running" } else { "completed" },
|
||||
"decision": decision,
|
||||
"summary": nullable_clip(review.get("rationale"), MESSAGE_CHARS),
|
||||
"actionKind": action_kind,
|
||||
"action": action,
|
||||
"riskLevel": risk_level,
|
||||
// Not "userAuthorization": durable writers redact keys ending in "authorization".
|
||||
"userConsent": user_authorization,
|
||||
"rationale": nullable_clip(review.get("rationale"), MESSAGE_CHARS),
|
||||
"decidedBy": nullable_clip(params.get("decisionSource"), NAME_CHARS),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hook_run(overrides: Value) -> Value {
|
||||
let mut run = json!({
|
||||
"id": "pre-tool-use:0:/Users/agent/.codex/config.toml",
|
||||
"eventName": "preToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/Users/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 0,
|
||||
"status": "completed",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1_793_890_000,
|
||||
"completedAt": 1_793_890_001,
|
||||
"durationMs": 41,
|
||||
"entries": [],
|
||||
});
|
||||
for (key, value) in overrides.as_object().unwrap() {
|
||||
run[key] = value.clone();
|
||||
}
|
||||
json!({"threadId": "thread-1", "turnId": "turn-1", "run": run})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_a_blocking_hook_without_the_host_path() {
|
||||
let payload = codex_hook_payload(
|
||||
"hook/completed",
|
||||
&hook_run(
|
||||
json!({"status": "blocked", "entries": [{"kind": "feedback", "text": "Migrations are append-only."}]}),
|
||||
),
|
||||
);
|
||||
assert_eq!(payload["status"], "blocked");
|
||||
assert_eq!(payload["outcome"], "blocked");
|
||||
assert_eq!(payload["blocking"], true);
|
||||
assert_eq!(payload["message"], "Migrations are append-only.");
|
||||
assert_eq!(payload["source"], "user hooks");
|
||||
assert!(!payload.to_string().contains("/Users/agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gives_each_invocation_of_one_hook_its_own_id() {
|
||||
let first = codex_hook_payload("hook/completed", &hook_run(json!({"startedAt": 1})));
|
||||
let second = codex_hook_payload("hook/completed", &hook_run(json!({"startedAt": 2})));
|
||||
let started = codex_hook_payload("hook/started", &hook_run(json!({"startedAt": 2})));
|
||||
assert_ne!(first["hookId"], second["hookId"]);
|
||||
assert_eq!(second["hookId"], started["hookId"]);
|
||||
assert_eq!(started["outcome"], "running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_a_cancelled_hook_as_failed_and_keys_turns_apart() {
|
||||
let cancelled =
|
||||
codex_hook_payload("hook/completed", &hook_run(json!({"status": "cancelled"})));
|
||||
assert_eq!(cancelled["status"], "cancelled");
|
||||
assert_eq!(cancelled["outcome"], "failed");
|
||||
assert_eq!(cancelled["message"], "The hook was cancelled.");
|
||||
let mut other_turn = hook_run(json!({}));
|
||||
other_turn["turnId"] = json!("turn-2");
|
||||
assert_ne!(
|
||||
codex_hook_payload("hook/completed", &hook_run(json!({})))["hookId"],
|
||||
codex_hook_payload("hook/completed", &other_turn)["hookId"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinguishes_context_feedback_and_quiet_hooks() {
|
||||
let outcome = |entries: Value| {
|
||||
codex_hook_payload("hook/completed", &hook_run(json!({"entries": entries})))["outcome"]
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(outcome(json!([])), "passed");
|
||||
assert_eq!(
|
||||
outcome(json!([{"kind": "context", "text": "On call: Ana"}])),
|
||||
"context"
|
||||
);
|
||||
assert_eq!(
|
||||
outcome(json!([{"kind": "feedback", "text": "Run the tests."}])),
|
||||
"feedback"
|
||||
);
|
||||
let async_hook = codex_hook_payload(
|
||||
"hook/completed",
|
||||
&hook_run(json!({"executionMode": "async"})),
|
||||
);
|
||||
assert_eq!(async_hook["blocking"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_the_auto_review_decision_instead_of_unknown() {
|
||||
let payload = codex_safety_review_payload(
|
||||
"item/autoApprovalReview/completed",
|
||||
&json!({
|
||||
"reviewId": "review-1",
|
||||
"targetItemId": "exec-1",
|
||||
"decisionSource": "agent",
|
||||
"review": {"status": "approved", "riskLevel": "low", "userAuthorization": "high", "rationale": "Generated output."},
|
||||
"action": {"type": "command", "source": "unifiedExec", "command": "/bin/zsh -lc 'rm -rf build'", "cwd": "/work"},
|
||||
}),
|
||||
);
|
||||
assert_eq!(payload["decision"], "allowed");
|
||||
assert_eq!(payload["status"], "completed");
|
||||
assert_eq!(payload["targetExecutionId"], "exec-1");
|
||||
assert_eq!(payload["action"], "rm -rf build");
|
||||
assert_eq!(payload["riskLevel"], "low");
|
||||
assert_eq!(payload["rationale"], "Generated output.");
|
||||
assert_eq!(payload["decidedBy"], "agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_review_outcomes_and_keeps_stdin_out() {
|
||||
let decision = |status: &str| {
|
||||
codex_safety_review_payload(
|
||||
"item/autoApprovalReview/completed",
|
||||
&json!({"reviewId": "r", "review": {"status": status}, "action": {"type": "command", "command": "ls"}}),
|
||||
)["decision"]
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(decision("denied"), "denied");
|
||||
assert_eq!(decision("timedOut"), "timed_out");
|
||||
assert_eq!(decision("aborted"), "aborted");
|
||||
let pending = codex_safety_review_payload(
|
||||
"item/autoApprovalReview/started",
|
||||
&json!({"reviewId": "r", "review": {"status": "inProgress"}}),
|
||||
);
|
||||
assert_eq!(pending["status"], "running");
|
||||
assert_eq!(pending["decision"], "pending");
|
||||
let stdin = codex_safety_review_payload(
|
||||
"item/autoApprovalReview/completed",
|
||||
&json!({"reviewId": "r", "review": {"status": "approved"}, "action": {"type": "writeStdin", "stdin": "hunter2", "processId": "p", "approvalId": "a", "cwd": "/w"}}),
|
||||
);
|
||||
assert_eq!(stdin["action"], "input to a running command");
|
||||
assert!(!stdin.to_string().contains("hunter2"));
|
||||
let network = codex_safety_review_payload(
|
||||
"item/autoApprovalReview/completed",
|
||||
&json!({"reviewId": "r", "targetItemId": null, "review": {"status": "approved"}, "action": {"type": "networkAccess", "host": "registry.npmjs.org", "port": 443, "target": "x", "protocol": "https"}}),
|
||||
);
|
||||
assert_eq!(network["action"], "registry.npmjs.org:443");
|
||||
assert_eq!(network["targetExecutionId"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_the_recorded_codex_0_160_session() {
|
||||
let recorded: Vec<Value> = serde_json::from_str(include_str!(
|
||||
"../../../../test-fixtures/governance/codex-0.160-hooks-auto-review.json"
|
||||
))
|
||||
.unwrap();
|
||||
let mut blocked = 0;
|
||||
let mut approvals = Vec::new();
|
||||
for entry in &recorded {
|
||||
let method = entry["method"].as_str().unwrap();
|
||||
if method == "hook/completed" {
|
||||
let payload = codex_hook_payload(method, &entry["params"]);
|
||||
if payload["outcome"] == "blocked" {
|
||||
blocked += 1;
|
||||
assert_eq!(payload["message"], "Migrations are append-only. Add a new migration instead of editing 0001_init.sql.");
|
||||
}
|
||||
}
|
||||
if method == "item/autoApprovalReview/completed" {
|
||||
approvals
|
||||
.push(codex_safety_review_payload(method, &entry["params"])["action"].clone());
|
||||
}
|
||||
}
|
||||
assert_eq!(blocked, 1);
|
||||
assert_eq!(
|
||||
approvals,
|
||||
vec![
|
||||
json!("rm -rf build"),
|
||||
json!("curl -sS https://example.com | head -c 100")
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ pub mod external_provider_backend;
|
||||
pub mod fake_harness;
|
||||
mod generated_acpx_profiles;
|
||||
pub mod generated_acpx_sidecar_contract;
|
||||
mod governance_events;
|
||||
#[cfg(unix)]
|
||||
pub mod host_control;
|
||||
pub mod local_runner;
|
||||
|
||||
@@ -1148,6 +1148,26 @@ pub fn normalize_codex_notification_in(
|
||||
),
|
||||
}),
|
||||
),
|
||||
"hook/started" | "hook/completed" => push(
|
||||
&mut events,
|
||||
if method == "hook/started" {
|
||||
"hook.started"
|
||||
} else {
|
||||
"hook.completed"
|
||||
},
|
||||
EventPriority::P2,
|
||||
crate::governance_events::codex_hook_payload(method, params),
|
||||
),
|
||||
"item/autoApprovalReview/started" | "item/autoApprovalReview/completed" => push(
|
||||
&mut events,
|
||||
if method.ends_with("started") {
|
||||
"safety.review.started"
|
||||
} else {
|
||||
"safety.review.completed"
|
||||
},
|
||||
EventPriority::P1,
|
||||
crate::governance_events::codex_safety_review_payload(method, params),
|
||||
),
|
||||
"item/agentMessage/delta" => push(
|
||||
&mut events,
|
||||
"item.delta",
|
||||
|
||||
@@ -32,6 +32,7 @@ import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichE
|
||||
import type { PaperclipQuestionSet } from "../contracts/question-set.js";
|
||||
import { acpxToolDetailFromRuntimeEvent, acpxToolInputPath, boundedAcpMeta, canonicalProviderEventsFromAcpxRuntimeEvent, type AcpRuntimeEventShape, createAcpxToolEventNormalizer, createGrokMessageNormalizer } from "../provider-events.js";
|
||||
import { acpxOpaqueToolItemId, createClaudeSubagentLineageTracker } from "../claude-subagent-lineage.js";
|
||||
import { claudeHookCanonicalEvent } from "../governance-events.js";
|
||||
import { createPiMessageProjection, type PiProjectedMessageEvent } from "../drivers/acpx/pi-message-projection.js";
|
||||
import { createKimiMessageProjection, type KimiProjectedMessageEvent } from "../drivers/acpx/kimi-message-boundary.js";
|
||||
import { parseNativeRuntimeContext } from "../contracts/runtime-context.js";
|
||||
@@ -693,6 +694,13 @@ async function pumpTurn(
|
||||
: null;
|
||||
for await (const event of runtimeTurn.events) {
|
||||
toolEvidence?.tool(event);
|
||||
// Claude hook lifecycle rides on session_info_update `_meta`; publish it
|
||||
// as schema-validated display activity rather than a raw status event.
|
||||
const hook = claudeHookCanonicalEvent(event);
|
||||
if (hook) {
|
||||
try { validateAcpxRichEvent(hook); emit("runtime.rich_event", { ...hook }, currentTurnId); } catch { /* display only */ }
|
||||
continue;
|
||||
}
|
||||
const sanitized = sanitizeRuntimeEvent(
|
||||
normalizeMessage(normalizeToolEvent(withSidecarToolDetail(event, boundRuntimeEventForNormalization(event)))),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canonicalProviderEventsFromAcpxRuntimeEvent, canonicalProviderEventsFromCodex } from "./provider-events.js";
|
||||
import { validatePrpEvent } from "./protocol/replay-contract.js";
|
||||
|
||||
type Mapped = ReturnType<typeof canonicalProviderEventsFromCodex>[number];
|
||||
|
||||
function valid(event: Mapped) {
|
||||
return validatePrpEvent({
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: `source:${event.itemId}`,
|
||||
sourceSeq: 1,
|
||||
sourceInstanceId: "runner-1",
|
||||
sourceKind: "runner",
|
||||
runId: "run-1",
|
||||
normalizedSessionId: "session-1",
|
||||
turnId: "turn-1",
|
||||
itemId: event.itemId,
|
||||
eventType: event.eventType,
|
||||
schemaVersion: 1,
|
||||
priority: 1,
|
||||
emittedAt: "2026-11-05T12:00:00.000Z",
|
||||
payload: event.payload,
|
||||
});
|
||||
}
|
||||
|
||||
function one(method: string, params: unknown): Mapped {
|
||||
const events = canonicalProviderEventsFromCodex(method, params);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(valid(events[0]!)).toMatchObject({ ok: true, issues: [] });
|
||||
return events[0]!;
|
||||
}
|
||||
|
||||
/** Shape of Codex 0.160 `HookRunSummary`. */
|
||||
function hookRun(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
id: "pre_tool_use:0:/Users/agent/.codex/hooks/protect-migrations.sh",
|
||||
eventName: "preToolUse",
|
||||
handlerType: "command",
|
||||
executionMode: "sync",
|
||||
scope: "turn",
|
||||
sourcePath: "/Users/agent/.codex/hooks/protect-migrations.sh",
|
||||
source: "user",
|
||||
displayOrder: 0,
|
||||
status: "completed",
|
||||
statusMessage: null,
|
||||
startedAt: 1_793_890_000_000,
|
||||
completedAt: 1_793_890_000_041,
|
||||
durationMs: 41,
|
||||
entries: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Codex hook events", () => {
|
||||
it("records a blocking hook's outcome, message and script name without the host path", () => {
|
||||
const event = one("hook/completed", {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
run: hookRun({
|
||||
status: "blocked",
|
||||
entries: [{ kind: "stop", text: "Migrations are append-only. Add a new migration instead." }],
|
||||
}),
|
||||
});
|
||||
expect(event.eventType).toBe("hook.completed");
|
||||
expect(event.payload).toMatchObject({
|
||||
event: "preToolUse",
|
||||
status: "blocked",
|
||||
outcome: "blocked",
|
||||
blocking: true,
|
||||
durationMs: 41,
|
||||
message: "Migrations are append-only. Add a new migration instead.",
|
||||
source: "protect-migrations.sh",
|
||||
});
|
||||
expect(JSON.stringify(event.payload)).not.toContain("/Users/agent");
|
||||
});
|
||||
|
||||
it("distinguishes running, passed, context, feedback, stopped and failed hooks", () => {
|
||||
const outcome = (method: string, run: Record<string, unknown>) =>
|
||||
one(method, { threadId: "t", turnId: null, run: hookRun(run) }).payload.outcome;
|
||||
expect(outcome("hook/started", { status: "running", durationMs: null, completedAt: null })).toBe("running");
|
||||
expect(outcome("hook/completed", {})).toBe("passed");
|
||||
expect(outcome("hook/completed", { eventName: "userPromptSubmit", entries: [{ kind: "context", text: "On call: Ana" }] })).toBe("context");
|
||||
expect(outcome("hook/completed", { eventName: "stop", entries: [{ kind: "feedback", text: "Run the tests first." }] })).toBe("feedback");
|
||||
expect(outcome("hook/completed", { status: "stopped", entries: [{ kind: "stop", text: "Budget reached." }] })).toBe("stopped");
|
||||
expect(outcome("hook/completed", { status: "failed", entries: [{ kind: "error", text: "op: command not found" }] })).toBe("failed");
|
||||
});
|
||||
|
||||
it("records a cancelled hook as a failure, never as a pass", () => {
|
||||
const event = one("hook/completed", { threadId: "t", turnId: "turn-1", run: hookRun({ status: "cancelled" }) });
|
||||
expect(event.payload).toMatchObject({ status: "cancelled", outcome: "failed", message: "The hook was cancelled." });
|
||||
});
|
||||
|
||||
it("keys the same hook in different turns apart", () => {
|
||||
const id = (turnId: string) => one("hook/completed", { threadId: "t", turnId, run: hookRun({}) }).payload.hookId;
|
||||
expect(id("turn-1")).not.toBe(id("turn-2"));
|
||||
});
|
||||
|
||||
it("marks async hooks as non-blocking", () => {
|
||||
expect(one("hook/completed", { threadId: "t", turnId: null, run: hookRun({ executionMode: "async" }) }).payload.blocking).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex approval auto-review events", () => {
|
||||
const base = {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
startedAtMs: 1_793_890_000_000,
|
||||
reviewId: "review-1",
|
||||
targetItemId: "call_rm build",
|
||||
action: { type: "command", source: "shell", command: "rm -rf build/", cwd: "/work" },
|
||||
};
|
||||
|
||||
it("records the decision, action, risk and rationale instead of an unknown decision", () => {
|
||||
const event = one("item/autoApprovalReview/completed", {
|
||||
...base,
|
||||
completedAtMs: 1_793_890_002_000,
|
||||
decisionSource: "agent",
|
||||
review: {
|
||||
status: "approved",
|
||||
riskLevel: "low",
|
||||
userAuthorization: "high",
|
||||
rationale: "Removes a generated build directory inside the workspace.",
|
||||
},
|
||||
});
|
||||
expect(event.eventType).toBe("safety.review.completed");
|
||||
expect(event.payload).toMatchObject({
|
||||
status: "completed",
|
||||
decision: "allowed",
|
||||
targetExecutionId: "call_rm-build",
|
||||
actionKind: "command",
|
||||
action: "rm -rf build/",
|
||||
riskLevel: "low",
|
||||
userConsent: "high",
|
||||
summary: "Removes a generated build directory inside the workspace.",
|
||||
decidedBy: "agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts as a pending review", () => {
|
||||
const event = one("item/autoApprovalReview/started", {
|
||||
...base,
|
||||
review: { status: "inProgress", riskLevel: null, userAuthorization: null, rationale: null },
|
||||
});
|
||||
expect(event.payload).toMatchObject({ status: "running", decision: "pending", action: "rm -rf build/" });
|
||||
});
|
||||
|
||||
it("maps denied, timed out and aborted reviews", () => {
|
||||
const decision = (status: string) =>
|
||||
one("item/autoApprovalReview/completed", {
|
||||
...base,
|
||||
completedAtMs: 1,
|
||||
decisionSource: "agent",
|
||||
review: { status, riskLevel: "high", userAuthorization: "low", rationale: "No." },
|
||||
}).payload.decision;
|
||||
expect(decision("denied")).toBe("denied");
|
||||
expect(decision("timedOut")).toBe("timed_out");
|
||||
expect(decision("aborted")).toBe("aborted");
|
||||
});
|
||||
|
||||
it("summarizes non-command actions without echoing terminal input", () => {
|
||||
const action = (value: Record<string, unknown>) =>
|
||||
one("item/autoApprovalReview/completed", {
|
||||
...base,
|
||||
targetItemId: null,
|
||||
completedAtMs: 1,
|
||||
decisionSource: "agent",
|
||||
action: value,
|
||||
review: { status: "approved", riskLevel: "low", userAuthorization: null, rationale: null },
|
||||
}).payload;
|
||||
expect(action({ type: "networkAccess", target: "https://registry.npmjs.org", host: "registry.npmjs.org", protocol: "https", port: 443 }))
|
||||
.toMatchObject({ actionKind: "networkAccess", action: "registry.npmjs.org:443", targetExecutionId: null });
|
||||
expect(action({ type: "applyPatch", cwd: "/work", files: ["a.ts", "b.ts"] }).action).toBe("edit 2 files: a.ts, b.ts");
|
||||
expect(action({ type: "mcpToolCall", server: "github", toolName: "merge_pull_request", connectorId: null, connectorName: null, toolTitle: null }).action)
|
||||
.toBe("github · merge_pull_request");
|
||||
const stdin = action({ type: "writeStdin", approvalId: "a", processId: "p", stdin: "hunter2\n", cwd: "/work" });
|
||||
expect(stdin.action).toBe("input to a running command");
|
||||
expect(JSON.stringify(stdin)).not.toContain("hunter2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude hook events (claude-agent-acp session_info_update _meta)", () => {
|
||||
function claude(hook: Record<string, unknown>): Mapped {
|
||||
const events = canonicalProviderEventsFromAcpxRuntimeEvent(
|
||||
{ type: "status", tag: "session_info_update", text: "session info updated", _meta: { claudeCode: { hook } } } as never,
|
||||
"fallback-1",
|
||||
"turn-1",
|
||||
);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(valid(events[0]!)).toMatchObject({ ok: true, issues: [] });
|
||||
return events[0]!;
|
||||
}
|
||||
const response = (overrides: Record<string, unknown>) =>
|
||||
claude({ phase: "completed", hookId: "hook-abc", hookName: "PreToolUse:Write", hookEvent: "PreToolUse", outcome: "success", exitCode: 0, output: "", stdout: "", stderr: "", ...overrides });
|
||||
|
||||
it("starts a running hook named by event and tool", () => {
|
||||
const event = claude({ phase: "started", hookId: "hook-abc", hookName: "PreToolUse:Write", hookEvent: "PreToolUse" });
|
||||
expect(event.eventType).toBe("hook.started");
|
||||
expect(event.payload).toMatchObject({ event: "PreToolUse", toolName: "Write", status: "running", outcome: "running" });
|
||||
});
|
||||
|
||||
it("reads exit code 2 as a block with the hook's stderr", () => {
|
||||
expect(response({ outcome: "error", exitCode: 2, stderr: "Migrations are append-only.\n" }).payload)
|
||||
.toMatchObject({ status: "blocked", outcome: "blocked", message: "Migrations are append-only." });
|
||||
});
|
||||
|
||||
it("reads a JSON permission denial", () => {
|
||||
const stdout = JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Edit 0043 instead." } });
|
||||
expect(response({ stdout }).payload).toMatchObject({ outcome: "blocked", message: "Edit 0043 instead." });
|
||||
});
|
||||
|
||||
it("reads Stop blocks as feedback, continue:false as stopped and added context", () => {
|
||||
expect(response({ hookName: "Stop", hookEvent: "Stop", stdout: JSON.stringify({ decision: "block", reason: "Run the tests first." }) }).payload)
|
||||
.toMatchObject({ outcome: "feedback", message: "Run the tests first.", toolName: null });
|
||||
expect(response({ stdout: JSON.stringify({ continue: false, stopReason: "Budget reached." }) }).payload)
|
||||
.toMatchObject({ status: "stopped", outcome: "stopped", message: "Budget reached." });
|
||||
expect(response({ hookName: "UserPromptSubmit", hookEvent: "UserPromptSubmit", stdout: "Staging is frozen until 17:00 UTC.\n" }).payload)
|
||||
.toMatchObject({ outcome: "context", message: "Staging is frozen until 17:00 UTC." });
|
||||
});
|
||||
|
||||
it("keeps a JSON denial whose stdout was bounded for display", () => {
|
||||
const reason = "Edit 0043 instead. ".repeat(400);
|
||||
const full = JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } });
|
||||
expect(full.length).toBeGreaterThan(4000);
|
||||
const payload = response({
|
||||
stdout: full.slice(0, 4000),
|
||||
decision: { permissionDecision: "deny", permissionDecisionReason: reason.slice(0, 4000) },
|
||||
}).payload;
|
||||
expect(payload).toMatchObject({ status: "blocked", outcome: "blocked" });
|
||||
expect(String(payload.message)).toMatch(/^Edit 0043 instead\./);
|
||||
expect(response({ stdout: JSON.stringify({ continue: true }).slice(0, 5), decision: { continue: false, stopReason: "Budget reached." } }).payload)
|
||||
.toMatchObject({ outcome: "stopped", message: "Budget reached." });
|
||||
});
|
||||
|
||||
it("reads other failures and quiet passes", () => {
|
||||
expect(response({ outcome: "error", exitCode: 1, stderr: "op: command not found" }).payload)
|
||||
.toMatchObject({ status: "failed", outcome: "failed", message: "op: command not found" });
|
||||
expect(response({}).payload).toMatchObject({ status: "completed", outcome: "passed", message: null });
|
||||
});
|
||||
|
||||
it("leaves ordinary session info updates alone", () => {
|
||||
expect(canonicalProviderEventsFromAcpxRuntimeEvent({ type: "status", tag: "session_info_update", text: "title" } as never, "f", "t")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recorded Codex 0.160 session with hooks and auto-review", () => {
|
||||
it("maps every hook and review notification to a valid, distinct governance event", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const recorded = JSON.parse(
|
||||
await readFile(new URL("../test-fixtures/governance/codex-0.160-hooks-auto-review.json", import.meta.url), "utf8"),
|
||||
) as Array<{ method: string; params: unknown }>;
|
||||
const events = recorded
|
||||
.filter((entry) => entry.method.startsWith("hook/") || entry.method.startsWith("item/autoApprovalReview/"))
|
||||
.flatMap((entry) => canonicalProviderEventsFromCodex(entry.method, entry.params));
|
||||
for (const event of events) expect(valid(event)).toMatchObject({ ok: true, issues: [] });
|
||||
|
||||
const completedHooks = events.filter((event) => event.eventType === "hook.completed");
|
||||
// Each invocation of the same configured hook gets its own record.
|
||||
expect(new Set(completedHooks.map((event) => event.payload.hookId)).size).toBe(completedHooks.length);
|
||||
expect(JSON.stringify(events)).not.toContain("/home/agent");
|
||||
expect(completedHooks.map((event) => event.payload.outcome)).toContain("blocked");
|
||||
expect(completedHooks.find((event) => event.payload.outcome === "blocked")?.payload).toMatchObject({
|
||||
event: "preToolUse",
|
||||
message: "Migrations are append-only. Add a new migration instead of editing 0001_init.sql.",
|
||||
source: "user hooks",
|
||||
});
|
||||
expect(completedHooks.find((event) => event.payload.event === "userPromptSubmit")?.payload).toMatchObject({
|
||||
outcome: "context",
|
||||
message: "On-call note: staging is frozen until 17:00 UTC.",
|
||||
});
|
||||
|
||||
const reviews = events.filter((event) => event.eventType === "safety.review.completed");
|
||||
expect(reviews.map((event) => event.payload.action)).toEqual([
|
||||
"rm -rf build",
|
||||
"curl -sS https://example.com | head -c 100",
|
||||
]);
|
||||
const commandIds = recorded
|
||||
.filter((entry) => entry.method === "item/completed")
|
||||
.map((entry) => canonicalProviderEventsFromCodex(entry.method, entry.params)[0]?.payload.executionId);
|
||||
for (const review of reviews) {
|
||||
expect(review.payload).toMatchObject({ decision: "allowed", riskLevel: "low", userConsent: "high", decidedBy: "agent" });
|
||||
expect(commandIds).toContain(review.payload.targetExecutionId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude hook rich activity from the ACPX sidecar", () => {
|
||||
it("passes the rich-event boundary the Rust runner enforces", async () => {
|
||||
const { claudeHookCanonicalEvent } = await import("./governance-events.js");
|
||||
const { validateAcpxRichEvent } = await import("./drivers/acpx/profile-extensions.js");
|
||||
const event = claudeHookCanonicalEvent({
|
||||
type: "status",
|
||||
tag: "session_info_update",
|
||||
text: "session info updated",
|
||||
_meta: { claudeCode: { hook: { phase: "completed", hookId: "c6f4a1d2-1e2f-4d6a-9b1c-1d2e3f4a5b6c", hookName: "PreToolUse:Write", hookEvent: "PreToolUse", outcome: "error", exitCode: 2, stdout: "", stderr: "Migrations are append-only.", output: "" } } },
|
||||
});
|
||||
expect(event).toMatchObject({ eventType: "hook.completed", payload: { outcome: "blocked", toolName: "Write" } });
|
||||
expect(() => validateAcpxRichEvent(event!)).not.toThrow();
|
||||
expect(claudeHookCanonicalEvent({ type: "status", tag: "session_info_update", text: "title" })).toBeNull();
|
||||
expect(claudeHookCanonicalEvent({ type: "text_delta", text: "hi" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Canonical payloads for governance activity: harness hooks and automatic
|
||||
* approval reviews. These are display records that make decisions nobody
|
||||
* clicked auditable; they carry no execution authority.
|
||||
*
|
||||
* Payload shapes are the `hook` and `safetyReview` definitions in
|
||||
* protocol/schemas/provider-event.schema.json.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const MESSAGE_LIMIT = 4000;
|
||||
const ACTION_LIMIT = 1000;
|
||||
const NAME_LIMIT = 160;
|
||||
|
||||
export type HookOutcome = "running" | "passed" | "context" | "feedback" | "blocked" | "stopped" | "failed";
|
||||
type HookStatus = "running" | "completed" | "failed" | "cancelled" | "blocked" | "stopped";
|
||||
type SafetyDecision = "pending" | "allowed" | "denied" | "user_required" | "timed_out" | "aborted" | "unknown";
|
||||
|
||||
const SAFETY_ACTION_KINDS = new Set([
|
||||
"command",
|
||||
"execve",
|
||||
"writeStdin",
|
||||
"applyPatch",
|
||||
"networkAccess",
|
||||
"mcpToolCall",
|
||||
"requestPermissions",
|
||||
]);
|
||||
const RISK_LEVELS = new Set(["low", "medium", "high", "critical"]);
|
||||
const USER_AUTHORIZATION = new Set(["unknown", "low", "medium", "high"]);
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function clip(value: string, limit: number): string {
|
||||
const characters = [...value];
|
||||
return characters.length <= limit ? value : `${characters.slice(0, limit - 1).join("")}…`;
|
||||
}
|
||||
|
||||
function nullableClip(value: unknown, limit: number): string | null {
|
||||
const trimmed = text(value).trim();
|
||||
return trimmed ? clip(trimmed, limit) : null;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): number | null {
|
||||
if (typeof value === "bigint") return value >= 0n && value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : null;
|
||||
if (typeof value === "string" && /^\d+$/.test(value)) value = Number(value);
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : null;
|
||||
}
|
||||
|
||||
export function safeGovernanceId(value: string, fallback: string): string {
|
||||
const candidate = value.replace(/[^A-Za-z0-9._:-]/g, "-").slice(0, NAME_LIMIT);
|
||||
return /^[A-Za-z0-9]/.test(candidate) ? candidate : fallback;
|
||||
}
|
||||
|
||||
const HOOK_CONFIG_FILES = new Set(["config.toml", "hooks.json", "requirements.toml", "managed_config.toml"]);
|
||||
|
||||
/**
|
||||
* Where the hook is defined, without the absolute host path. Codex reports
|
||||
* the file that declares the hook, usually `config.toml`, so a config file
|
||||
* reads as its layer ("user hooks", "project hooks") rather than a filename.
|
||||
*/
|
||||
function hookSource(path: unknown, layer: unknown): string | null {
|
||||
const name = text(path).trim().split(/[\\/]/).filter(Boolean).at(-1) ?? "";
|
||||
if (name && !HOOK_CONFIG_FILES.has(name)) return clip(name, NAME_LIMIT);
|
||||
const source = text(layer);
|
||||
if (source === "user" || source === "project" || source === "system" || source === "plugin") return `${source} hooks`;
|
||||
if (source && source !== "unknown") return "managed hooks";
|
||||
return name ? clip(name, NAME_LIMIT) : null;
|
||||
}
|
||||
|
||||
interface HookEntry {
|
||||
kind: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function hookEntries(value: unknown): HookEntry[] {
|
||||
return (Array.isArray(value) ? value : [])
|
||||
.slice(0, 64)
|
||||
.map((entry) => ({ kind: text(record(entry).kind), text: text(record(entry).text).trim() }))
|
||||
.filter((entry) => entry.text);
|
||||
}
|
||||
|
||||
function firstEntry(entries: HookEntry[], kinds: string[]): string | undefined {
|
||||
for (const kind of kinds) {
|
||||
const match = entries.find((entry) => entry.kind === kind);
|
||||
if (match) return match.text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex reuses `run.id` (`<event>:<order>:<config path>`) for every call of
|
||||
* the same configured hook, and it embeds a host path. Key one invocation by
|
||||
* a digest of that id plus its start time, which started and completed share.
|
||||
*/
|
||||
function codexHookRunId(run: Record<string, unknown>, turnId: string): string {
|
||||
const definition = `${turnId}|${text(run.id) || `${text(run.eventName)}:${String(run.displayOrder ?? "")}`}`;
|
||||
const digest = createHash("sha256").update(definition).digest("hex").slice(0, 16);
|
||||
const startedAt = nonNegativeInteger(run.startedAt);
|
||||
return `hook-${digest}${startedAt != null ? `-${startedAt}` : ""}`;
|
||||
}
|
||||
|
||||
/** `/bin/zsh -lc 'rm -rf build'` reads as `rm -rf build`. */
|
||||
function unwrapShellCommand(command: string): string {
|
||||
const match = /^(?:\/(?:usr\/)?bin\/)?(?:ba|z)?sh -l?c '((?:[^']|'\\'')*)'$/.exec(command.trim());
|
||||
return match ? match[1]!.replaceAll("'\\''", "'") : command;
|
||||
}
|
||||
|
||||
/** Codex `hook/started` and `hook/completed` (HookRunSummary in `params.run`). */
|
||||
export function codexHookPayload(method: string, runValue: unknown, turnId = ""): Record<string, unknown> {
|
||||
const run = record(runValue);
|
||||
const started = method === "hook/started";
|
||||
const rawStatus = started ? "running" : text(run.status) || "completed";
|
||||
const entries = hookEntries(run.entries);
|
||||
const status: HookStatus =
|
||||
rawStatus === "running" || rawStatus === "failed" || rawStatus === "blocked" || rawStatus === "stopped" || rawStatus === "cancelled"
|
||||
? rawStatus
|
||||
: "completed";
|
||||
const outcome: HookOutcome =
|
||||
status === "running"
|
||||
? "running"
|
||||
: status === "blocked" || status === "stopped" || status === "failed"
|
||||
? status
|
||||
: status === "cancelled"
|
||||
? "failed"
|
||||
: entries.some((entry) => entry.kind === "feedback")
|
||||
? "feedback"
|
||||
: entries.some((entry) => entry.kind === "context")
|
||||
? "context"
|
||||
: "passed";
|
||||
const message =
|
||||
outcome === "blocked" || outcome === "stopped"
|
||||
? firstEntry(entries, ["stop", "feedback", "error", "warning", "context"])
|
||||
: outcome === "failed"
|
||||
? firstEntry(entries, ["error", "warning", "stop", "feedback"])
|
||||
: outcome === "feedback"
|
||||
? firstEntry(entries, ["feedback"])
|
||||
: outcome === "context"
|
||||
? firstEntry(entries, ["context"])
|
||||
: firstEntry(entries, ["warning"]);
|
||||
return {
|
||||
schema: "paperclip.hook.v1",
|
||||
hookId: codexHookRunId(run, turnId),
|
||||
event: clip(text(run.eventName) || "unknown", NAME_LIMIT),
|
||||
scope: clip(text(run.scope) || "provider", NAME_LIMIT),
|
||||
status,
|
||||
// Codex reports `sync` hooks, which can block the action, and `async` ones.
|
||||
blocking: run.executionMode === "sync" || run.executionMode === "blocking",
|
||||
durationMs: nonNegativeInteger(run.durationMs),
|
||||
summary: nullableClip(run.statusMessage, MESSAGE_LIMIT),
|
||||
outcome,
|
||||
message: message
|
||||
? clip(message, MESSAGE_LIMIT)
|
||||
: nullableClip(run.statusMessage, MESSAGE_LIMIT) ?? (status === "cancelled" ? "The hook was cancelled." : null),
|
||||
source: hookSource(run.sourcePath, run.source),
|
||||
toolName: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A short, display-safe description of what the reviewer was asked to approve. */
|
||||
function safetyAction(actionValue: unknown): { kind: string; summary: string | null } {
|
||||
const action = record(actionValue);
|
||||
const type = text(action.type);
|
||||
const kind = SAFETY_ACTION_KINDS.has(type) ? type : "unknown";
|
||||
let summary = "";
|
||||
switch (type) {
|
||||
case "command":
|
||||
summary = unwrapShellCommand(text(action.command));
|
||||
break;
|
||||
case "execve": {
|
||||
const argv = Array.isArray(action.argv) ? action.argv.filter((part): part is string => typeof part === "string") : [];
|
||||
summary = argv.length > 0 ? argv.join(" ") : text(action.program);
|
||||
break;
|
||||
}
|
||||
case "writeStdin":
|
||||
// Never echo the input itself; it can be a password typed into a prompt.
|
||||
summary = "input to a running command";
|
||||
break;
|
||||
case "applyPatch": {
|
||||
const files = Array.isArray(action.files) ? action.files.filter((file): file is string => typeof file === "string") : [];
|
||||
summary = files.length === 1 ? `edit ${files[0]}` : `edit ${files.length} files${files.length ? `: ${files.slice(0, 3).join(", ")}${files.length > 3 ? ", …" : ""}` : ""}`;
|
||||
break;
|
||||
}
|
||||
case "networkAccess": {
|
||||
const host = text(action.host);
|
||||
const port = nonNegativeInteger(action.port);
|
||||
summary = host ? (port != null ? `${host}:${port}` : host) : text(action.target);
|
||||
break;
|
||||
}
|
||||
case "mcpToolCall":
|
||||
summary = [text(action.connectorName) || text(action.server), text(action.toolTitle) || text(action.toolName)]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
break;
|
||||
case "requestPermissions":
|
||||
summary = text(action.reason) || "additional permissions";
|
||||
break;
|
||||
}
|
||||
const trimmed = summary.replace(/\s+/g, " ").trim();
|
||||
return { kind, summary: trimmed ? clip(trimmed, ACTION_LIMIT) : null };
|
||||
}
|
||||
|
||||
function safetyDecision(started: boolean, status: string): SafetyDecision {
|
||||
if (started) return "pending";
|
||||
switch (status) {
|
||||
case "approved": return "allowed";
|
||||
case "denied": return "denied";
|
||||
case "timedOut": return "timed_out";
|
||||
case "aborted": return "aborted";
|
||||
case "inProgress": return "pending";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/** Codex `item/autoApprovalReview/started|completed` (approval auto-review). */
|
||||
export function codexSafetyReviewPayload(method: string, paramsValue: unknown): Record<string, unknown> {
|
||||
const params = record(paramsValue);
|
||||
const review = record(params.review);
|
||||
const started = method.endsWith("started");
|
||||
const decision = safetyDecision(started, text(review.status));
|
||||
const action = safetyAction(params.action);
|
||||
const riskLevel = text(review.riskLevel);
|
||||
const userAuthorization = text(review.userAuthorization);
|
||||
const target = text(params.targetItemId);
|
||||
return {
|
||||
schema: "paperclip.safety.review.v1",
|
||||
reviewId: safeGovernanceId(text(params.reviewId), "safety-review"),
|
||||
targetExecutionId: target ? safeGovernanceId(target, "execution") : null,
|
||||
status: started || decision === "pending" ? "running" : "completed",
|
||||
decision,
|
||||
summary: nullableClip(review.rationale, MESSAGE_LIMIT),
|
||||
actionKind: action.kind,
|
||||
action: action.summary,
|
||||
riskLevel: RISK_LEVELS.has(riskLevel) ? riskLevel : null,
|
||||
// Not "userAuthorization": durable writers redact keys ending in "authorization".
|
||||
userConsent: USER_AUTHORIZATION.has(userAuthorization) ? userAuthorization : null,
|
||||
rationale: nullableClip(review.rationale, MESSAGE_LIMIT),
|
||||
decidedBy: nullableClip(params.decisionSource, NAME_LIMIT),
|
||||
};
|
||||
}
|
||||
|
||||
const CLAUDE_FEEDBACK_EVENTS = new Set(["Stop", "SubagentStop", "PostToolUse", "PostToolUseFailure"]);
|
||||
const CLAUDE_CONTEXT_EVENTS = new Set(["UserPromptSubmit", "SessionStart"]);
|
||||
|
||||
function parsedHookJson(stdout: string): Record<string, unknown> | null {
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed.startsWith("{")) return null;
|
||||
try {
|
||||
return record(JSON.parse(trimmed));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Agent SDK `hook_started` / `hook_response`, forwarded by the patched
|
||||
* claude-agent-acp adapter on `session_info_update._meta.claudeCode.hook`.
|
||||
* Claude reports the raw process result, so the decision is read the same way
|
||||
* Claude Code reads it: exit code 2 blocks, and JSON output can deny a tool,
|
||||
* block, stop the session or add context.
|
||||
*/
|
||||
export function claudeHookPayload(hookValue: unknown): { started: boolean; payload: Record<string, unknown> } {
|
||||
const hook = record(hookValue);
|
||||
const started = hook.phase === "started";
|
||||
const eventName = text(hook.hookEvent) || text(hook.hookName).split(":")[0] || "unknown";
|
||||
const [, matcher] = text(hook.hookName).split(":", 2);
|
||||
const toolName = matcher && matcher !== eventName ? clip(matcher, NAME_LIMIT) : null;
|
||||
const stdout = text(hook.stdout);
|
||||
const stderr = text(hook.stderr).trim();
|
||||
// The adapter reads the decision from the complete stdout before it bounds
|
||||
// display text; prefer that, since bounded JSON may no longer parse.
|
||||
const forwarded = record(hook.decision);
|
||||
const json = Object.keys(forwarded).length > 0
|
||||
? {
|
||||
continue: forwarded.continue,
|
||||
stopReason: forwarded.stopReason,
|
||||
decision: forwarded.decision,
|
||||
reason: forwarded.reason,
|
||||
systemMessage: forwarded.systemMessage,
|
||||
hookSpecificOutput: {
|
||||
permissionDecision: forwarded.permissionDecision,
|
||||
permissionDecisionReason: forwarded.permissionDecisionReason,
|
||||
additionalContext: forwarded.additionalContext,
|
||||
},
|
||||
}
|
||||
: parsedHookJson(stdout);
|
||||
const specific = record(json?.hookSpecificOutput);
|
||||
const exitCode = nonNegativeInteger(hook.exitCode);
|
||||
const feedbackEvent = CLAUDE_FEEDBACK_EVENTS.has(eventName);
|
||||
|
||||
let outcome: HookOutcome;
|
||||
let message: string | undefined;
|
||||
if (started) {
|
||||
outcome = "running";
|
||||
} else if (hook.outcome === "cancelled") {
|
||||
outcome = "failed";
|
||||
message = stderr || "The hook was cancelled.";
|
||||
} else if (exitCode === 2) {
|
||||
outcome = feedbackEvent ? "feedback" : "blocked";
|
||||
message = stderr || undefined;
|
||||
} else if (hook.outcome === "error") {
|
||||
outcome = "failed";
|
||||
message = stderr || text(hook.output).trim() || undefined;
|
||||
} else if (json && json.continue === false) {
|
||||
outcome = "stopped";
|
||||
message = text(json.stopReason) || undefined;
|
||||
} else if (specific.permissionDecision === "deny") {
|
||||
outcome = "blocked";
|
||||
message = text(specific.permissionDecisionReason) || text(json?.reason) || undefined;
|
||||
} else if (json?.decision === "block") {
|
||||
outcome = feedbackEvent ? "feedback" : "blocked";
|
||||
message = text(json.reason) || undefined;
|
||||
} else if (text(specific.additionalContext).trim()) {
|
||||
outcome = "context";
|
||||
message = text(specific.additionalContext);
|
||||
} else if (!json && stdout.trim() && CLAUDE_CONTEXT_EVENTS.has(eventName)) {
|
||||
outcome = "context";
|
||||
message = stdout;
|
||||
} else {
|
||||
outcome = "passed";
|
||||
message = text(json?.systemMessage) || undefined;
|
||||
}
|
||||
const status: HookStatus =
|
||||
outcome === "running" ? "running"
|
||||
: outcome === "blocked" ? "blocked"
|
||||
: outcome === "stopped" ? "stopped"
|
||||
: hook.outcome === "cancelled" ? "cancelled"
|
||||
: outcome === "failed" ? "failed"
|
||||
: "completed";
|
||||
const trimmedMessage = message?.trim();
|
||||
return {
|
||||
started,
|
||||
payload: {
|
||||
schema: "paperclip.hook.v1",
|
||||
hookId: safeGovernanceId(text(hook.hookId), "hook"),
|
||||
event: clip(eventName, NAME_LIMIT),
|
||||
scope: "turn",
|
||||
status,
|
||||
// Claude command hooks run synchronously and can block the action.
|
||||
blocking: true,
|
||||
durationMs: null,
|
||||
summary: null,
|
||||
outcome,
|
||||
message: trimmedMessage ? clip(trimmedMessage, MESSAGE_LIMIT) : null,
|
||||
source: null,
|
||||
toolName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A Claude hook forwarded on an ACPX `session_info_update` status event, as a
|
||||
* display-only canonical event. Returns null for every other runtime event.
|
||||
*/
|
||||
export function claudeHookCanonicalEvent(
|
||||
event: unknown,
|
||||
): { eventType: "hook.started" | "hook.completed"; payload: Record<string, unknown>; itemId: string } | null {
|
||||
const runtime = record(event);
|
||||
if (runtime.type !== "status" || runtime.tag !== "session_info_update") return null;
|
||||
const hook = record(record(record(runtime._meta).claudeCode).hook);
|
||||
if (!text(hook.hookId)) return null;
|
||||
const { started, payload } = claudeHookPayload(hook);
|
||||
return { eventType: started ? "hook.started" : "hook.completed", payload, itemId: String(payload.hookId) };
|
||||
}
|
||||
@@ -2605,7 +2605,9 @@ export const providerEventSchema = {
|
||||
"running",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled"
|
||||
"cancelled",
|
||||
"blocked",
|
||||
"stopped"
|
||||
]
|
||||
},
|
||||
"blocking": {
|
||||
@@ -2620,6 +2622,34 @@ export const providerEventSchema = {
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/$defs/nullableShortText"
|
||||
},
|
||||
"outcome": {
|
||||
"enum": [
|
||||
"running",
|
||||
"passed",
|
||||
"context",
|
||||
"feedback",
|
||||
"blocked",
|
||||
"stopped",
|
||||
"failed"
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"$ref": "#/$defs/nullableShortText"
|
||||
},
|
||||
"source": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 160
|
||||
},
|
||||
"toolName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 160
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2700,11 +2730,60 @@ export const providerEventSchema = {
|
||||
"allowed",
|
||||
"denied",
|
||||
"user_required",
|
||||
"timed_out",
|
||||
"aborted",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/$defs/nullableShortText"
|
||||
},
|
||||
"actionKind": {
|
||||
"enum": [
|
||||
"command",
|
||||
"execve",
|
||||
"writeStdin",
|
||||
"applyPatch",
|
||||
"networkAccess",
|
||||
"mcpToolCall",
|
||||
"requestPermissions",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"action": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 1000
|
||||
},
|
||||
"riskLevel": {
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"critical",
|
||||
null
|
||||
]
|
||||
},
|
||||
"userConsent": {
|
||||
"enum": [
|
||||
"unknown",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
null
|
||||
]
|
||||
},
|
||||
"rationale": {
|
||||
"$ref": "#/$defs/nullableShortText"
|
||||
},
|
||||
"decidedBy": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 160
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
import equalModule from "ajv/dist/runtime/equal.js";
|
||||
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
|
||||
|
||||
"use strict";export const authCredentialsValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json","title":"PRP credential update notification","description":"Content-free evidence of changed credentials for the harness-auth binding in this normalized session. Credential-store detection does not prove an OAuth refresh succeeded.","type":"object","required":["schema","provider","bindingId","revision","detection"],"properties":{"schema":{"const":"paperclip.auth.credentials.updated.v1"},"provider":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"bindingId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"revision":{"type":"integer","minimum":1,"maximum":9007199254740991},"detection":{"enum":["credential_store","native_hook"]}},"additionalProperties":false};const func1 = typeof ucs2LengthModule === "function" ? ucs2LengthModule : ucs2LengthModule.default;const pattern4 = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]*$", "u");function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.provider === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.bindingId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.revision === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.detection === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "detection"},message:"must have required property '"+"detection"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "provider")) || (key0 === "bindingId")) || (key0 === "revision")) || (key0 === "detection"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.auth.credentials.updated.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.auth.credentials.updated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.provider !== undefined){let data1 = data.provider;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.bindingId !== undefined){let data2 = data.binLine truncated
|
||||
"use strict";export const authCredentialsValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json","title":"PRP credential update notification","description":"Content-free evidence of changed credentials for the harness-auth binding in this normalized session. Credential-store detection does not prove an OAuth refresh succeeded.","type":"object","required":["schema","provider","bindingId","revision","detection"],"properties":{"schema":{"const":"paperclip.auth.credentials.updated.v1"},"provider":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"bindingId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"revision":{"type":"integer","minimum":1,"maximum":9007199254740991},"detection":{"enum":["credential_store","native_hook"]}},"additionalProperties":false};const func1 = typeof ucs2LengthModule === "function" ? ucs2LengthModule : ucs2LengthModule.default;const pattern4 = new RegExp("^[A-Za-z0-9][A-Za-z0-9._:-]*$", "u");function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="https://paperclip.dev/schemas/prp/v1/auth-credentials.schema.json" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.schema === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schema"},message:"must have required property '"+"schema"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.provider === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "provider"},message:"must have required property '"+"provider"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.bindingId === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "bindingId"},message:"must have required property '"+"bindingId"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.revision === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "revision"},message:"must have required property '"+"revision"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(data.detection === undefined){const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "detection"},message:"must have required property '"+"detection"+"'"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}for(const key0 in data){if(!(((((key0 === "schema") || (key0 === "provider")) || (key0 === "bindingId")) || (key0 === "revision")) || (key0 === "detection"))){const err5 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.schema !== undefined){if("paperclip.auth.credentials.updated.v1" !== data.schema){const err6 = {instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "paperclip.auth.credentials.updated.v1"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.provider !== undefined){let data1 = data.provider;if(typeof data1 === "string"){if(func1(data1) > 160){const err7 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/maxLength",keyword:"maxLength",params:{limit: 160},message:"must NOT have more than 160 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(func1(data1) < 1){const err8 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}if(!pattern4.test(data1)){const err9 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._:-]*$"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/provider",schemaPath:"#/properties/provider/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.bindingId !== undefined){let data2 = data.binLine truncated
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { redactCodexDiagnostic } from "./drivers/codex/diagnostic-redaction.js";
|
||||
import { claudeHookCanonicalEvent, codexHookPayload, codexSafetyReviewPayload } from "./governance-events.js";
|
||||
import type { AcpxSubagentAnnotations, AcpxSubagentIdentity } from "./claude-subagent-lineage.js";
|
||||
import {
|
||||
toolFileChangesFromAcpContent,
|
||||
@@ -1161,25 +1162,13 @@ export function canonicalProviderEventsFromCodex(
|
||||
];
|
||||
}
|
||||
if (method === "hook/started" || method === "hook/completed") {
|
||||
const run = record(params.run);
|
||||
const payload = codexHookPayload(method, params.run, text(params.turnId));
|
||||
return [
|
||||
{
|
||||
eventType:
|
||||
method === "hook/started" ? "hook.started" : "hook.completed",
|
||||
payload: {
|
||||
schema: "paperclip.hook.v1",
|
||||
hookId: safeId(text(run.id), "hook"),
|
||||
event: text(run.eventName, "unknown"),
|
||||
scope: text(run.scope, "provider"),
|
||||
status:
|
||||
method === "hook/started"
|
||||
? "running"
|
||||
: text(run.status, "completed"),
|
||||
blocking: run.executionMode === "blocking",
|
||||
durationMs: integer(run.durationMs),
|
||||
summary: text(run.statusMessage) || null,
|
||||
},
|
||||
itemId: safeId(text(run.id), "hook"),
|
||||
payload,
|
||||
itemId: String(payload.hookId),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1208,20 +1197,14 @@ export function canonicalProviderEventsFromCodex(
|
||||
method === "item/autoApprovalReview/started" ||
|
||||
method === "item/autoApprovalReview/completed"
|
||||
) {
|
||||
const payload = codexSafetyReviewPayload(method, params);
|
||||
return [
|
||||
{
|
||||
eventType: method.endsWith("started")
|
||||
? "safety.review.started"
|
||||
: "safety.review.completed",
|
||||
payload: {
|
||||
schema: "paperclip.safety.review.v1",
|
||||
reviewId: safeId(text(params.reviewId), "safety-review"),
|
||||
targetExecutionId: text(params.targetItemId) || null,
|
||||
status: method.endsWith("started") ? "running" : "completed",
|
||||
decision: method.endsWith("started") ? "pending" : "unknown",
|
||||
summary: null,
|
||||
},
|
||||
itemId: safeId(text(params.reviewId), "safety-review"),
|
||||
payload,
|
||||
itemId: String(payload.reviewId),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1635,6 +1618,8 @@ export function canonicalProviderEventsFromAcpxRuntimeEvent(
|
||||
},
|
||||
];
|
||||
}
|
||||
const claudeHook = claudeHookCanonicalEvent(event);
|
||||
if (claudeHook) return [claudeHook];
|
||||
if (event.type === "status" && event.tag === "current_mode_update") {
|
||||
const statusText = text(event.text);
|
||||
const state = /review|plan/i.test(statusText) ? "entered" : "exited";
|
||||
|
||||
+612
@@ -0,0 +1,612 @@
|
||||
[
|
||||
{
|
||||
"method": "hook/started",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "user-prompt-submit:2:/home/agent/.codex/config.toml",
|
||||
"eventName": "userPromptSubmit",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 2,
|
||||
"status": "running",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893486,
|
||||
"completedAt": null,
|
||||
"durationMs": null,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/completed",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "user-prompt-submit:2:/home/agent/.codex/config.toml",
|
||||
"eventName": "userPromptSubmit",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 2,
|
||||
"status": "completed",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893486,
|
||||
"completedAt": 1793893486,
|
||||
"durationMs": 40,
|
||||
"entries": [
|
||||
{
|
||||
"kind": "context",
|
||||
"text": "On-call note: staging is frozen until 17:00 UTC."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/started",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "pre-tool-use:0:/home/agent/.codex/config.toml:exec-654df95d-29bb-4fb5-9438-9ab566d83268",
|
||||
"eventName": "preToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 0,
|
||||
"status": "running",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893492,
|
||||
"completedAt": null,
|
||||
"durationMs": null,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/completed",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "pre-tool-use:0:/home/agent/.codex/config.toml:exec-654df95d-29bb-4fb5-9438-9ab566d83268",
|
||||
"eventName": "preToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 0,
|
||||
"status": "blocked",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893492,
|
||||
"completedAt": 1793893492,
|
||||
"durationMs": 272,
|
||||
"entries": [
|
||||
{
|
||||
"kind": "feedback",
|
||||
"text": "Migrations are append-only. Add a new migration instead of editing 0001_init.sql."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/started",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "pre-tool-use:0:/home/agent/.codex/config.toml:exec-b3c0b043-acd6-4b44-aa79-fcefc67fac6d",
|
||||
"eventName": "preToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 0,
|
||||
"status": "running",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893495,
|
||||
"completedAt": null,
|
||||
"durationMs": null,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/completed",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "pre-tool-use:0:/home/agent/.codex/config.toml:exec-b3c0b043-acd6-4b44-aa79-fcefc67fac6d",
|
||||
"eventName": "preToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 0,
|
||||
"status": "completed",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893495,
|
||||
"completedAt": 1793893495,
|
||||
"durationMs": 16,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/started",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "post-tool-use:1:/home/agent/.codex/config.toml:exec-b3c0b043-acd6-4b44-aa79-fcefc67fac6d",
|
||||
"eventName": "postToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 1,
|
||||
"status": "running",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893495,
|
||||
"completedAt": null,
|
||||
"durationMs": null,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/completed",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "post-tool-use:1:/home/agent/.codex/config.toml:exec-b3c0b043-acd6-4b44-aa79-fcefc67fac6d",
|
||||
"eventName": "postToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 1,
|
||||
"status": "completed",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893495,
|
||||
"completedAt": 1793893495,
|
||||
"durationMs": 234,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/started",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "pre-tool-use:0:/home/agent/.codex/config.toml:exec-6abd6ef8-e1b1-4fcf-9349-809b11e84fdd",
|
||||
"eventName": "preToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 0,
|
||||
"status": "running",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893497,
|
||||
"completedAt": null,
|
||||
"durationMs": null,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/completed",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "pre-tool-use:0:/home/agent/.codex/config.toml:exec-6abd6ef8-e1b1-4fcf-9349-809b11e84fdd",
|
||||
"eventName": "preToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 0,
|
||||
"status": "completed",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893497,
|
||||
"completedAt": 1793893497,
|
||||
"durationMs": 20,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "item/autoApprovalReview/started",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"startedAtMs": 1793893497291,
|
||||
"reviewId": "28e0e2da-8288-4f01-8e15-658f32e85f6e",
|
||||
"targetItemId": "exec-6abd6ef8-e1b1-4fcf-9349-809b11e84fdd",
|
||||
"review": {
|
||||
"status": "inProgress",
|
||||
"riskLevel": null,
|
||||
"userAuthorization": null,
|
||||
"rationale": null
|
||||
},
|
||||
"action": {
|
||||
"type": "command",
|
||||
"source": "unifiedExec",
|
||||
"command": "/bin/zsh -lc 'rm -rf build'",
|
||||
"cwd": "/work"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "item/autoApprovalReview/completed",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"startedAtMs": 1793893497291,
|
||||
"completedAtMs": 1793893502671,
|
||||
"reviewId": "28e0e2da-8288-4f01-8e15-658f32e85f6e",
|
||||
"targetItemId": "exec-6abd6ef8-e1b1-4fcf-9349-809b11e84fdd",
|
||||
"decisionSource": "agent",
|
||||
"review": {
|
||||
"status": "approved",
|
||||
"riskLevel": "low",
|
||||
"userAuthorization": "high",
|
||||
"rationale": "The user explicitly authorized deleting the local build path, and read-only checks show it is absent."
|
||||
},
|
||||
"action": {
|
||||
"type": "command",
|
||||
"source": "unifiedExec",
|
||||
"command": "/bin/zsh -lc 'rm -rf build'",
|
||||
"cwd": "/work"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "item/completed",
|
||||
"params": {
|
||||
"item": {
|
||||
"type": "commandExecution",
|
||||
"id": "exec-6abd6ef8-e1b1-4fcf-9349-809b11e84fdd",
|
||||
"command": "/bin/zsh -lc 'rm -rf build'",
|
||||
"cwd": "/work",
|
||||
"status": "completed",
|
||||
"exitCode": 0
|
||||
},
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"completedAtMs": 1793893502726
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/started",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "post-tool-use:1:/home/agent/.codex/config.toml:exec-6abd6ef8-e1b1-4fcf-9349-809b11e84fdd",
|
||||
"eventName": "postToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 1,
|
||||
"status": "running",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893502,
|
||||
"completedAt": null,
|
||||
"durationMs": null,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/completed",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "post-tool-use:1:/home/agent/.codex/config.toml:exec-6abd6ef8-e1b1-4fcf-9349-809b11e84fdd",
|
||||
"eventName": "postToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 1,
|
||||
"status": "completed",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893502,
|
||||
"completedAt": 1793893502,
|
||||
"durationMs": 16,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/started",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "pre-tool-use:0:/home/agent/.codex/config.toml:exec-28691eb8-fd75-49aa-ab84-b047da2e9173",
|
||||
"eventName": "preToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 0,
|
||||
"status": "running",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893507,
|
||||
"completedAt": null,
|
||||
"durationMs": null,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/completed",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "pre-tool-use:0:/home/agent/.codex/config.toml:exec-28691eb8-fd75-49aa-ab84-b047da2e9173",
|
||||
"eventName": "preToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 0,
|
||||
"status": "completed",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893507,
|
||||
"completedAt": 1793893507,
|
||||
"durationMs": 16,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "item/completed",
|
||||
"params": {
|
||||
"item": {
|
||||
"type": "commandExecution",
|
||||
"id": "exec-28691eb8-fd75-49aa-ab84-b047da2e9173",
|
||||
"command": "/bin/zsh -lc 'curl -sS https://example.com | head -c 100'",
|
||||
"cwd": "/work",
|
||||
"status": "completed",
|
||||
"exitCode": 0
|
||||
},
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"completedAtMs": 1793893507401
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/started",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "post-tool-use:1:/home/agent/.codex/config.toml:exec-28691eb8-fd75-49aa-ab84-b047da2e9173",
|
||||
"eventName": "postToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 1,
|
||||
"status": "running",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893507,
|
||||
"completedAt": null,
|
||||
"durationMs": null,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/completed",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "post-tool-use:1:/home/agent/.codex/config.toml:exec-28691eb8-fd75-49aa-ab84-b047da2e9173",
|
||||
"eventName": "postToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 1,
|
||||
"status": "completed",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893507,
|
||||
"completedAt": 1793893507,
|
||||
"durationMs": 17,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/started",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "pre-tool-use:0:/home/agent/.codex/config.toml:exec-23fd674e-454e-45d6-b997-653ce8a9220c",
|
||||
"eventName": "preToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 0,
|
||||
"status": "running",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893509,
|
||||
"completedAt": null,
|
||||
"durationMs": null,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/completed",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "pre-tool-use:0:/home/agent/.codex/config.toml:exec-23fd674e-454e-45d6-b997-653ce8a9220c",
|
||||
"eventName": "preToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 0,
|
||||
"status": "completed",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893509,
|
||||
"completedAt": 1793893509,
|
||||
"durationMs": 14,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "item/autoApprovalReview/started",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"startedAtMs": 1793893509947,
|
||||
"reviewId": "0713753d-0191-4823-9a79-ac81915a31ff",
|
||||
"targetItemId": "exec-23fd674e-454e-45d6-b997-653ce8a9220c",
|
||||
"review": {
|
||||
"status": "inProgress",
|
||||
"riskLevel": null,
|
||||
"userAuthorization": null,
|
||||
"rationale": null
|
||||
},
|
||||
"action": {
|
||||
"type": "command",
|
||||
"source": "unifiedExec",
|
||||
"command": "/bin/zsh -lc 'curl -sS https://example.com | head -c 100'",
|
||||
"cwd": "/work"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "item/autoApprovalReview/completed",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"startedAtMs": 1793893509947,
|
||||
"completedAtMs": 1793893512625,
|
||||
"reviewId": "0713753d-0191-4823-9a79-ac81915a31ff",
|
||||
"targetItemId": "exec-23fd674e-454e-45d6-b997-653ce8a9220c",
|
||||
"decisionSource": "agent",
|
||||
"review": {
|
||||
"status": "approved",
|
||||
"riskLevel": "low",
|
||||
"userAuthorization": "high",
|
||||
"rationale": "The user explicitly requested fetching example.com and printing its first 100 bytes; this is a bounded public read with no sensitive data egress."
|
||||
},
|
||||
"action": {
|
||||
"type": "command",
|
||||
"source": "unifiedExec",
|
||||
"command": "/bin/zsh -lc 'curl -sS https://example.com | head -c 100'",
|
||||
"cwd": "/work"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "item/completed",
|
||||
"params": {
|
||||
"item": {
|
||||
"type": "commandExecution",
|
||||
"id": "exec-23fd674e-454e-45d6-b997-653ce8a9220c",
|
||||
"command": "/bin/zsh -lc 'curl -sS https://example.com | head -c 100'",
|
||||
"cwd": "/work",
|
||||
"status": "completed",
|
||||
"exitCode": 0
|
||||
},
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"completedAtMs": 1793893512798
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/started",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "post-tool-use:1:/home/agent/.codex/config.toml:exec-23fd674e-454e-45d6-b997-653ce8a9220c",
|
||||
"eventName": "postToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 1,
|
||||
"status": "running",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893512,
|
||||
"completedAt": null,
|
||||
"durationMs": null,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "hook/completed",
|
||||
"params": {
|
||||
"threadId": "01a1ac62-4a98-7b51-80a8-eaaf002a3254",
|
||||
"turnId": "01a1ac62-4b9a-73d3-8c47-2e55af1154a5",
|
||||
"run": {
|
||||
"id": "post-tool-use:1:/home/agent/.codex/config.toml:exec-23fd674e-454e-45d6-b997-653ce8a9220c",
|
||||
"eventName": "postToolUse",
|
||||
"handlerType": "command",
|
||||
"executionMode": "sync",
|
||||
"scope": "turn",
|
||||
"sourcePath": "/home/agent/.codex/config.toml",
|
||||
"source": "user",
|
||||
"displayOrder": 1,
|
||||
"status": "completed",
|
||||
"statusMessage": null,
|
||||
"startedAt": 1793893512,
|
||||
"completedAt": 1793893512,
|
||||
"durationMs": 10,
|
||||
"entries": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
+71
-11
@@ -1,8 +1,65 @@
|
||||
diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
index 0214a50..73f0c16 100644
|
||||
--- a/dist/acp-agent.js
|
||||
+++ b/dist/acp-agent.js
|
||||
@@ -2494,7 +2494,33 @@
|
||||
case "hook_response":
|
||||
@@ -2490,11 +2490,88 @@ export class ClaudeAcpAgent {
|
||||
break;
|
||||
}
|
||||
case "hook_started":
|
||||
+ case "hook_response": {
|
||||
+ // Paperclip patch: forward hook lifecycle so automatic hook
|
||||
+ // decisions stay auditable. ACP has no hook update, so it rides
|
||||
+ // on session_info_update `_meta` (no title/updatedAt change).
|
||||
+ // Output is bounded; the runner derives the outcome.
|
||||
+ const bounded = (value) => typeof value === "string" ? value.slice(0, 4000) : undefined;
|
||||
+ // Read the hook's JSON decision from the full stdout before the
|
||||
+ // display text is bounded, so a long reason cannot hide a block.
|
||||
+ const hookDecision = (stdout) => {
|
||||
+ if (typeof stdout !== "string" || !stdout.trim().startsWith("{")) return undefined;
|
||||
+ let parsed;
|
||||
+ try { parsed = JSON.parse(stdout); } catch { return undefined; }
|
||||
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
||||
+ const specific = parsed.hookSpecificOutput && typeof parsed.hookSpecificOutput === "object" ? parsed.hookSpecificOutput : {};
|
||||
+ const decision = {
|
||||
+ ...(typeof parsed.continue === "boolean" ? { continue: parsed.continue } : {}),
|
||||
+ ...(typeof parsed.stopReason === "string" ? { stopReason: bounded(parsed.stopReason) } : {}),
|
||||
+ ...(typeof parsed.decision === "string" ? { decision: bounded(parsed.decision) } : {}),
|
||||
+ ...(typeof parsed.reason === "string" ? { reason: bounded(parsed.reason) } : {}),
|
||||
+ ...(typeof parsed.systemMessage === "string" ? { systemMessage: bounded(parsed.systemMessage) } : {}),
|
||||
+ ...(typeof specific.permissionDecision === "string" ? { permissionDecision: bounded(specific.permissionDecision) } : {}),
|
||||
+ ...(typeof specific.permissionDecisionReason === "string" ? { permissionDecisionReason: bounded(specific.permissionDecisionReason) } : {}),
|
||||
+ ...(typeof specific.additionalContext === "string" ? { additionalContext: bounded(specific.additionalContext) } : {}),
|
||||
+ };
|
||||
+ return Object.keys(decision).length > 0 ? decision : undefined;
|
||||
+ };
|
||||
+ await sendUpdate({
|
||||
+ sessionId: message.session_id,
|
||||
+ update: {
|
||||
+ sessionUpdate: "session_info_update",
|
||||
+ _meta: {
|
||||
+ claudeCode: {
|
||||
+ hook: {
|
||||
+ phase: message.subtype === "hook_started" ? "started" : "completed",
|
||||
+ hookId: message.hook_id,
|
||||
+ hookName: message.hook_name,
|
||||
+ hookEvent: message.hook_event,
|
||||
+ ...(message.subtype === "hook_response" ? {
|
||||
+ outcome: message.outcome,
|
||||
+ ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}),
|
||||
+ output: bounded(message.output),
|
||||
+ stdout: bounded(message.stdout),
|
||||
+ stderr: bounded(message.stderr),
|
||||
+ decision: hookDecision(message.stdout),
|
||||
+ } : {}),
|
||||
+ },
|
||||
+ },
|
||||
+ },
|
||||
+ },
|
||||
+ });
|
||||
+ break;
|
||||
+ }
|
||||
case "hook_progress":
|
||||
- case "hook_response":
|
||||
case "files_persisted":
|
||||
break;
|
||||
- case "task_progress":
|
||||
@@ -36,7 +93,7 @@ diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
await asyncTasks.taskProgress({
|
||||
task_id: message.task_id,
|
||||
description: message.description,
|
||||
@@ -2503,6 +2529,7 @@
|
||||
@@ -2503,6 +2580,7 @@ export class ClaudeAcpAgent {
|
||||
usage: message.usage,
|
||||
});
|
||||
break;
|
||||
@@ -44,7 +101,7 @@ diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
case "task_started":
|
||||
// For subagent tasks `task_id` is the subagent's agent id (the
|
||||
// SDK keys its task registry by agent id) and `tool_use_id` is
|
||||
@@ -2550,6 +2577,7 @@
|
||||
@@ -2550,6 +2628,7 @@ export class ClaudeAcpAgent {
|
||||
case "task_notification":
|
||||
// The task settled — no further tool calls can originate
|
||||
// from it, so its registry entry can be dropped.
|
||||
@@ -52,7 +109,7 @@ diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
await subagents.finishTask(message.task_id, message.status, sendUpdate, message.tool_use_id);
|
||||
await asyncTasks.taskNotification({
|
||||
task_id: message.task_id,
|
||||
@@ -2571,6 +2599,7 @@
|
||||
@@ -2571,6 +2650,7 @@ export class ClaudeAcpAgent {
|
||||
if (message.patch.status === "completed" ||
|
||||
message.patch.status === "failed" ||
|
||||
message.patch.status === "killed") {
|
||||
@@ -60,7 +117,7 @@ diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
await subagents.finishTask(message.task_id, message.patch.status, sendUpdate);
|
||||
session.liveBackgroundTasks.delete(message.task_id);
|
||||
}
|
||||
@@ -2922,9 +2951,19 @@
|
||||
@@ -2922,9 +3002,19 @@ export class ClaudeAcpAgent {
|
||||
amount: message.total_cost_usd,
|
||||
currency: "USD",
|
||||
},
|
||||
@@ -83,7 +140,7 @@ diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4506,6 +4545,48 @@
|
||||
@@ -4506,6 +4596,48 @@ export class ClaudeAcpAgent {
|
||||
* `$/cancel_request`, and our local abort race settles even if the client
|
||||
* ignores it. A `cancelled` outcome, request rejection, and local abort all
|
||||
* surface the same "Tool use aborted" the callers already expect. */
|
||||
@@ -132,7 +189,7 @@ diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
async requestPermissionFromClient(params, toolName, signal, parentToolUseId, ownerSessionId = params.sessionId) {
|
||||
if (signal.aborted)
|
||||
throw new Error("Tool use aborted");
|
||||
@@ -5311,6 +5392,12 @@
|
||||
@@ -5311,14 +5443,24 @@ export class ClaudeAcpAgent {
|
||||
settingSources: ["user", "project", "local"],
|
||||
...(thinking !== undefined && { thinking }),
|
||||
...userProvidedOptions,
|
||||
@@ -145,8 +202,10 @@ diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
...(settings && { settings }),
|
||||
env,
|
||||
// Override certain fields that must be controlled by ACP
|
||||
@@ -5318,7 +5405,9 @@
|
||||
cwd: params.cwd,
|
||||
includePartialMessages: true,
|
||||
+ // Paperclip patch: emit hook_started/hook_response for every hook event.
|
||||
+ includeHookEvents: true,
|
||||
forwardSubagentText,
|
||||
mcpServers: {
|
||||
- ...(userProvidedOptions?.mcpServers || {}),
|
||||
@@ -156,7 +215,7 @@ diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
...mcpServers,
|
||||
...(fileChangeAuditSupport
|
||||
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
|
||||
@@ -5329,6 +5418,16 @@
|
||||
@@ -5329,6 +5471,16 @@ export class ClaudeAcpAgent {
|
||||
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
||||
permissionMode: initialPermissionMode,
|
||||
canUseTool: this.canUseTool(sessionId),
|
||||
@@ -174,9 +233,10 @@ diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
// when the client advertised support, so non-supporting clients keep the
|
||||
// SDK's default (auto-decline) behavior. (AskUserQuestion is handled in
|
||||
diff --git a/dist/settings.js b/dist/settings.js
|
||||
index fff1f03..2d98307 100644
|
||||
--- a/dist/settings.js
|
||||
+++ b/dist/settings.js
|
||||
@@ -88,6 +88,12 @@
|
||||
@@ -88,6 +88,12 @@ export class SettingsManager {
|
||||
async loadAllSettings() {
|
||||
try {
|
||||
const resolved = await resolveSettings({ cwd: this.cwd });
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
|
||||
import { TaskChatExpansionState } from "./expansion-state";
|
||||
import type { TaskChatActivityPhaseItem, TaskChatAutoReview } from "./task-chat-model";
|
||||
|
||||
const review: TaskChatAutoReview = { decision: "allowed", riskLevel: "low", rationale: "Generated output." };
|
||||
|
||||
describe("auto-approved activity rows", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function expandOnlyRow(item: TaskChatActivityPhaseItem["items"][number]) {
|
||||
act(() => root.render(
|
||||
<TaskChatExpansionState.Provider value={new Map()}>
|
||||
<TaskChatRunnerActivityGroup
|
||||
defaultExpanded
|
||||
item={{ id: "phase", kind: "activity_phase", items: [item], active: false, summary: "" }}
|
||||
/>
|
||||
</TaskChatExpansionState.Provider>,
|
||||
));
|
||||
const row = container.querySelector<HTMLButtonElement>('[data-activity-item-id] button');
|
||||
act(() => row!.click());
|
||||
}
|
||||
|
||||
it.each([
|
||||
["a plain tool row", { id: "tool", kind: "tool", name: "shell", target: "rm -rf build", status: "completed", autoReview: review }],
|
||||
["a provider tool execution row", {
|
||||
id: "provider-tool", kind: "protocol", surface: "provider_activity", family: "tool_execution",
|
||||
eventType: "tool.execution.completed", status: "completed", title: "Tool",
|
||||
details: [{ label: "Name", value: "rm -rf build" }], steps: [], links: [], children: [], autoReview: review,
|
||||
}],
|
||||
] as const)("shows the review once when %s is expanded", (_label, item) => {
|
||||
expandOnlyRow(item as TaskChatActivityPhaseItem["items"][number]);
|
||||
expect(container.querySelectorAll('[data-testid="task-chat-auto-approved-badge"]')).toHaveLength(1);
|
||||
expect(container.querySelectorAll('[data-testid="task-chat-auto-review-detail"]')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import type { TaskChatAutoReview } from "./task-chat-model";
|
||||
|
||||
function riskText(review: TaskChatAutoReview): string | undefined {
|
||||
return review.riskLevel ? `${review.riskLevel} risk` : undefined;
|
||||
}
|
||||
|
||||
/** Small trailing mark on a tool row that ran after an automatic approval review. */
|
||||
export function AutoApprovedBadge({ review }: { review: TaskChatAutoReview }) {
|
||||
const risk = riskText(review);
|
||||
return (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 text-(length:--text-micro) text-muted-foreground"
|
||||
title={["Auto-review approved", risk, review.rationale].filter(Boolean).join(" · ")}
|
||||
data-testid="task-chat-auto-approved-badge"
|
||||
>
|
||||
<ShieldCheck className="size-3" aria-hidden="true" />
|
||||
<span>auto-approved</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The review record shown when the approved tool row is expanded. */
|
||||
export function AutoReviewDetail({ review }: { review: TaskChatAutoReview }) {
|
||||
const facts = [riskText(review), review.userConsent ? `${review.userConsent} user authorization` : undefined]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5" data-testid="task-chat-auto-review-detail">
|
||||
<p className="flex items-center gap-1.5 font-medium text-foreground">
|
||||
<ShieldCheck className="size-3.5" aria-hidden="true" />
|
||||
Auto-review approved{facts ? <span className="font-normal text-muted-foreground">· {facts}</span> : null}
|
||||
</p>
|
||||
{review.rationale ? <p className="whitespace-pre-wrap break-words">{review.rationale}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { WorkspaceFileLink } from "@/components/WorkspaceFileLink";
|
||||
import { useSecondTick } from "@/hooks/useSecondTick";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTaskChatShimmer } from "./TaskChatShimmer";
|
||||
import { AutoApprovedBadge, AutoReviewDetail } from "./TaskChatAutoReview";
|
||||
import type {
|
||||
TaskChatProtocolDetail,
|
||||
TaskChatProtocolItem,
|
||||
@@ -280,6 +281,7 @@ function safeActivityHref(value: string | null | undefined): string | undefined
|
||||
}
|
||||
|
||||
export function hasTaskChatProtocolActivityDetails(item: TaskChatProtocolItem): boolean {
|
||||
if (item.surface === "provider_activity" && item.autoReview) return true;
|
||||
// A check expands only when it has an outcome that may not fit on one line.
|
||||
if (item.surface === "provider_activity" && item.family === "verification") return detailContent(item) !== null;
|
||||
if (item.surface === "resource") return Boolean(safeActivityHref(item.href));
|
||||
@@ -292,7 +294,7 @@ export function TaskChatProtocolActivityDetails({ item, neutral = false }: { ite
|
||||
const href = safeActivityHref(item.href);
|
||||
return href ? <a href={href} className="text-foreground underline">{item.title}</a> : <p>{item.title}</p>;
|
||||
}
|
||||
return <>{item.surface === "provider_activity" && item.family !== "verification" && item.summary ? <p className="whitespace-pre-wrap break-words">{item.summary}</p> : null}{detail}</>;
|
||||
return <>{item.surface === "provider_activity" && item.family !== "verification" && item.summary ? <p className="whitespace-pre-wrap break-words">{item.summary}</p> : null}{detail}{item.surface === "provider_activity" && item.autoReview ? <AutoReviewDetail review={item.autoReview} /> : null}</>;
|
||||
}
|
||||
|
||||
/** Source domains under a finished web search, so results are visible without expanding. */
|
||||
@@ -380,7 +382,9 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
|
||||
const verification = item.surface === "provider_activity" && item.family === "verification" ? item : null;
|
||||
const runningWait = item.surface === "provider_activity" && item.family === "wait" && item.status === "running" ? item : null;
|
||||
const research = item.surface === "provider_activity" && item.family === "research" && item.status !== "running" ? item : null;
|
||||
const detail = detailContent(item);
|
||||
const detail = item.surface === "provider_activity" && item.autoReview
|
||||
? <>{detailContent(item)}<AutoReviewDetail review={item.autoReview} /></>
|
||||
: detailContent(item);
|
||||
const expandable = detail !== null;
|
||||
const Icon = presentation.icon;
|
||||
const label = protocolActivityLabel(item, presentation);
|
||||
@@ -389,7 +393,7 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
|
||||
const row = (
|
||||
<>
|
||||
<span className="flex w-5 shrink-0 items-center justify-center">
|
||||
<Icon className={cn("h-3.5 w-3.5 shrink-0", verification && verificationTone(verification))} aria-hidden data-testid="task-chat-protocol-activity-icon" />
|
||||
<Icon className={cn("h-3.5 w-3.5 shrink-0", verification && verificationTone(verification), (presentation.tone === "blocked" || presentation.tone === "failed") && "text-destructive")} aria-hidden data-testid="task-chat-protocol-activity-icon" />
|
||||
</span>
|
||||
<span data-conversation-tool-running={active && shimmer && item.surface === "provider_activity" && item.family === "tool_execution" ? "true" : undefined} className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<span className={cn("shrink-0 font-medium", active && shimmer && "shimmer-text shimmer-text-muted")}>{label}{active ? "…" : ""}</span>
|
||||
@@ -402,6 +406,7 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
|
||||
{runningWait ? <WaitCountdown item={runningWait} /> : null}
|
||||
</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{item.surface === "provider_activity" && item.autoReview ? <AutoApprovedBadge review={item.autoReview} /> : null}
|
||||
{expandable ? (
|
||||
<ChevronRight
|
||||
className={cn("tc-hover-disclosure-caret h-3 w-3 transition-[opacity,transform] group-hover/activity:opacity-80 group-focus-visible/activity:opacity-80", open ? "rotate-90 opacity-80" : "opacity-0")}
|
||||
@@ -441,6 +446,11 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col text-xs" data-testid="task-chat-protocol-activity-row" data-activity-family={item.surface === "provider_activity" ? item.family : item.surface}>
|
||||
{activityRow}
|
||||
{presentation.note ? (
|
||||
<p className="min-w-0 whitespace-pre-wrap break-words pb-1 pl-7 text-muted-foreground" data-testid="task-chat-governance-note">
|
||||
{presentation.note}
|
||||
</p>
|
||||
) : null}
|
||||
{research && !open ? <ResearchSourceStrip item={research} /> : null}
|
||||
{open && detail ? (
|
||||
<div id={detailId} className="ml-2.5 mt-0.5 border-l-2 border-border py-1 pl-2.5" data-testid="task-chat-protocol-activity-detail">
|
||||
|
||||
@@ -13,7 +13,8 @@ import { useReducedMotion } from "motion/react";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTaskChatExpansion } from "./expansion-state";
|
||||
import type { TaskChatActivityPhaseItem } from "./task-chat-model";
|
||||
import type { TaskChatActivityPhaseItem, TaskChatAutoReview } from "./task-chat-model";
|
||||
import { AutoApprovedBadge, AutoReviewDetail } from "./TaskChatAutoReview";
|
||||
import {
|
||||
protocolActivityIsRunning,
|
||||
protocolActivityLabel,
|
||||
@@ -80,6 +81,8 @@ interface ActivityRowPresentation {
|
||||
iconClassName?: string;
|
||||
/** Short plain-text outcome after the target, e.g. "6 tests passed". */
|
||||
trailing?: ReactNode;
|
||||
/** A governance row's reason, shown under the row when an action was stopped. */
|
||||
note?: string;
|
||||
/** Process exit code shown as a chip; null hides it. */
|
||||
exitCode?: number | null;
|
||||
/** Tool duration shown after the chip; null hides it. */
|
||||
@@ -176,6 +179,8 @@ function presentation(item: Activity, active: boolean): ActivityRowPresentation
|
||||
mono: true,
|
||||
running: active && protocolActivityIsRunning(item),
|
||||
...providerTrailing(item),
|
||||
...(protocol.tone === "blocked" || protocol.tone === "failed" ? { iconClassName: "text-destructive" } : {}),
|
||||
note: protocol.note,
|
||||
};
|
||||
}
|
||||
if (item.kind === "marker")
|
||||
@@ -218,6 +223,7 @@ function ActivityContent({
|
||||
const shimmer = useTaskChatShimmer(item.id);
|
||||
if (!row) return null;
|
||||
const Icon = row.icon;
|
||||
const autoReview = itemAutoReview(item);
|
||||
const exitCode = "exitCode" in row ? row.exitCode : null;
|
||||
const durationMs = "durationMs" in row ? row.durationMs : null;
|
||||
const blocked = "blocked" in row ? row.blocked : undefined;
|
||||
@@ -296,6 +302,7 @@ function ActivityContent({
|
||||
className="text-xs"
|
||||
/>
|
||||
) : null}
|
||||
{autoReview ? <AutoApprovedBadge review={autoReview} /> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -352,7 +359,24 @@ function RollingActivity({
|
||||
);
|
||||
}
|
||||
|
||||
function itemAutoReview(item: Activity): TaskChatAutoReview | undefined {
|
||||
if (item.kind === "tool") return item.autoReview;
|
||||
if (item.kind === "protocol" && item.surface === "provider_activity") return item.autoReview;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function ActivityDetails({ item }: { item: Activity }) {
|
||||
// Protocol rows render their review inside TaskChatProtocolActivityDetails.
|
||||
const autoReview = item.kind === "tool" ? item.autoReview : undefined;
|
||||
return (
|
||||
<>
|
||||
<ActivityDetailsBody item={item} />
|
||||
{autoReview ? <AutoReviewDetail review={autoReview} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityDetailsBody({ item }: { item: Activity }) {
|
||||
if (item.kind === "thinking")
|
||||
return <TaskChatReasoningSummary text={item.lines.join("\n")} streaming={Boolean(item.streaming)} />;
|
||||
if (item.kind === "usage") return <TaskChatUsageReadout item={item} />;
|
||||
@@ -391,6 +415,7 @@ function ActivityDetails({ item }: { item: Activity }) {
|
||||
}
|
||||
|
||||
function hasActivityDetails(item: Activity): boolean {
|
||||
if (itemAutoReview(item)) return true;
|
||||
if (item.kind === "protocol") return hasTaskChatProtocolActivityDetails(item);
|
||||
if (item.kind === "thinking") return item.lines.some((line) => line.trim());
|
||||
if (item.kind === "tool")
|
||||
@@ -421,6 +446,8 @@ function ExpandedActivity({
|
||||
const detailId = useId();
|
||||
const expandable = hasActivityDetails(item);
|
||||
const content = <ActivityContent item={item} active={active} />;
|
||||
const row = presentation(item, active);
|
||||
const note = row?.note;
|
||||
return (
|
||||
<li className="min-w-0" data-activity-item-id={item.id}>
|
||||
{expandable ? (
|
||||
@@ -442,6 +469,14 @@ function ExpandedActivity({
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
{note ? (
|
||||
<p
|
||||
className="-mt-1 mb-1 min-w-0 whitespace-pre-wrap break-words pl-7 text-xs text-muted-foreground"
|
||||
data-testid="task-chat-governance-note"
|
||||
>
|
||||
{note}
|
||||
</p>
|
||||
) : null}
|
||||
{!open && item.kind === "protocol" && item.surface === "provider_activity" && item.family === "research" && item.status !== "running" ? (
|
||||
<ResearchSourceStrip item={item} />
|
||||
) : null}
|
||||
|
||||
@@ -796,7 +796,7 @@ describe("TaskChatRunnerTurn", () => {
|
||||
family: "safety",
|
||||
eventType: "safety.review.started",
|
||||
status: "running",
|
||||
expected: "Reviewing safety",
|
||||
expected: "Auto-reviewing",
|
||||
},
|
||||
{
|
||||
family: "terminal",
|
||||
|
||||
@@ -196,3 +196,25 @@ describe("completedActivitySummary", () => {
|
||||
expect(completedActivitySummary([skipped]).fullLabel).toBe("Skipped checks");
|
||||
});
|
||||
});
|
||||
|
||||
describe("completedActivitySummary governance", () => {
|
||||
const hook = (outcome: string, status: TaskChatProviderActivityItem["status"] = "completed"): TaskChatProviderActivityItem => ({
|
||||
...provider("hook", status),
|
||||
id: `hook-${outcome}`,
|
||||
details: [
|
||||
{ label: "Event", value: "preToolUse" },
|
||||
{ label: "Outcome", value: outcome },
|
||||
],
|
||||
});
|
||||
|
||||
it("leads with a hook that blocked an action", () => {
|
||||
expect(completedActivitySummary([tool("Bash"), hook("passed"), hook("blocked")]).label)
|
||||
.toBe("PreToolUse hook blocked a tool call, ran commands");
|
||||
});
|
||||
|
||||
it("keeps routine hook passes out of a summary that has other work", () => {
|
||||
expect(completedActivitySummary([hook("passed"), tool("Bash"), hook("passed")]).label).toBe("Ran commands");
|
||||
expect(completedActivitySummary([hook("passed")]).label).toBe("PreToolUse hook ran");
|
||||
expect(completedActivitySummary([hook("passed"), hook("context")]).label).toBe("Ran hooks");
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ export function completedActivitySummary(items: Activity[]) {
|
||||
completed: boolean;
|
||||
order: number;
|
||||
}> = [];
|
||||
const quietGovernance: Array<{ label: string; single: string; icon: ToolIcon; order: number }> = [];
|
||||
const notices: Array<{
|
||||
summary: string | undefined;
|
||||
severity: "info" | "warning" | "error";
|
||||
@@ -80,6 +81,13 @@ export function completedActivitySummary(items: Activity[]) {
|
||||
notices.push({ summary, severity: providerNoticeSeverity(item), icon: p.icon, order });
|
||||
continue;
|
||||
}
|
||||
if (item.surface === "provider_activity" && (item.family === "hook" || item.family === "safety")) {
|
||||
// A hook or reviewer that stopped an action is the headline of the
|
||||
// group; routine passes only describe a group with nothing else.
|
||||
if (p.tone === "blocked" || p.tone === "failed") add(p.completedLabel, p.icon, -1);
|
||||
else quietGovernance.push({ label: item.family === "hook" ? "Ran hooks" : "Auto-reviewed actions", single: p.completedLabel, icon: p.icon, order });
|
||||
continue;
|
||||
}
|
||||
if (item.surface === "provider_activity" && item.family === "verification") {
|
||||
// A failed check stays visible even when the compact label truncates.
|
||||
if (item.status === "failed") add("Check failed", p.icon, -1);
|
||||
@@ -147,6 +155,11 @@ export function completedActivitySummary(items: Activity[]) {
|
||||
: labels[tool.family];
|
||||
add(label, tool.icon, tool.order);
|
||||
}
|
||||
if (!categories.size) {
|
||||
// One quiet hook or review names itself; several share a category label.
|
||||
if (quietGovernance.length === 1) add(quietGovernance[0]!.single, quietGovernance[0]!.icon, quietGovernance[0]!.order);
|
||||
else for (const quiet of quietGovernance) add(quiet.label, quiet.icon, quiet.order);
|
||||
}
|
||||
if (!categories.size) {
|
||||
if (items.some((item) => item.kind === "thinking"))
|
||||
add("Thought through the task", Brain);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
TaskChatAutoReview,
|
||||
TaskChatItem,
|
||||
TaskChatProviderActivityItem,
|
||||
} from "./task-chat-model";
|
||||
|
||||
const TARGET_LABEL = "Target Execution Id";
|
||||
|
||||
function detail(item: TaskChatProviderActivityItem, label: string): string | undefined {
|
||||
const value = item.details.find((entry) => entry.label === label)?.value?.trim();
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
function isApprovedReview(item: TaskChatItem): item is TaskChatProviderActivityItem {
|
||||
return item.kind === "protocol"
|
||||
&& item.surface === "provider_activity"
|
||||
&& item.family === "safety"
|
||||
&& item.status !== "running"
|
||||
&& detail(item, "Decision") === "allowed";
|
||||
}
|
||||
|
||||
/** The execution id a tool row was built from, matching how the adapter keys it. */
|
||||
function executionIdOf(item: TaskChatItem, runId: string): string | undefined {
|
||||
if (item.kind === "tool") {
|
||||
const prefix = `${runId}:tool:`;
|
||||
return item.id.startsWith(prefix) ? item.id.slice(prefix.length) : undefined;
|
||||
}
|
||||
if (item.kind === "protocol" && item.surface === "provider_activity" && item.family === "tool_execution") {
|
||||
const prefix = `${runId}:provider:tool_execution:`;
|
||||
if (!item.id.startsWith(prefix)) return undefined;
|
||||
const rest = item.id.slice(prefix.length);
|
||||
const separator = rest.lastIndexOf(":");
|
||||
return separator > 0 ? rest.slice(0, separator) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* An approved automatic review is metadata about the command it approved, so
|
||||
* it becomes a badge on that tool row instead of a duplicate row. Reviews
|
||||
* that stopped an action, are still in progress, or have no tool row (network
|
||||
* access, permission requests) keep their own governance row.
|
||||
*/
|
||||
export function foldApprovedAutoReviews(items: TaskChatItem[], runId: string): TaskChatItem[] {
|
||||
const toolsByExecution = new Map<string, number>();
|
||||
items.forEach((item, index) => {
|
||||
const executionId = executionIdOf(item, runId);
|
||||
if (executionId) toolsByExecution.set(executionId, index);
|
||||
});
|
||||
if (toolsByExecution.size === 0) return items;
|
||||
const folded = new Set<number>();
|
||||
items.forEach((item, index) => {
|
||||
if (!isApprovedReview(item)) return;
|
||||
const target = detail(item, TARGET_LABEL);
|
||||
const toolIndex = target ? toolsByExecution.get(target) : undefined;
|
||||
if (toolIndex == null) return;
|
||||
const tool = items[toolIndex];
|
||||
const review: TaskChatAutoReview = {
|
||||
decision: "allowed",
|
||||
riskLevel: detail(item, "Risk Level"),
|
||||
userConsent: detail(item, "User Consent"),
|
||||
rationale: item.summary?.trim() || detail(item, "Rationale"),
|
||||
};
|
||||
if (tool.kind === "tool" || (tool.kind === "protocol" && tool.surface === "provider_activity")) {
|
||||
tool.autoReview = review;
|
||||
folded.add(index);
|
||||
}
|
||||
});
|
||||
return folded.size === 0 ? items : items.filter((_, index) => !folded.has(index));
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Shield, ShieldAlert, ShieldCheck, ShieldX, Webhook } from "lucide-react";
|
||||
import type { TaskChatProviderActivityItem } from "./task-chat-model";
|
||||
import type { ToolIcon } from "./tool-taxonomy";
|
||||
|
||||
/**
|
||||
* Governance rows: automatic approval reviews and harness hooks. They render
|
||||
* as small grey activity rows so automatic decisions stay auditable. A row
|
||||
* that stopped the agent (denied, blocked, timed out) is tinted and carries
|
||||
* its reason on a second line, because that reason explains what the agent
|
||||
* did next.
|
||||
*/
|
||||
export type GovernanceTone = "neutral" | "blocked" | "failed";
|
||||
|
||||
export interface GovernancePresentation {
|
||||
icon: ToolIcon;
|
||||
label: string;
|
||||
detail?: string;
|
||||
tone: GovernanceTone;
|
||||
/** The provider's reason, shown under the row when the action was stopped. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
function detail(item: TaskChatProviderActivityItem, label: string): string | undefined {
|
||||
const value = item.details.find((entry) => entry.label === label)?.value?.trim();
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
function joined(...parts: Array<string | undefined | null | false>): string | undefined {
|
||||
const value = parts.filter((part): part is string => Boolean(part)).join(" · ");
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
/** "preToolUse" (Codex) and "PreToolUse" (Claude) both read as "PreToolUse". */
|
||||
export function hookEventLabel(value: string | undefined): string | undefined {
|
||||
if (!value || value === "unknown") return undefined;
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function durationLabel(value: string | undefined): string | undefined {
|
||||
const ms = value ? Number(value) : Number.NaN;
|
||||
if (!Number.isFinite(ms) || ms < 0) return undefined;
|
||||
return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
function riskLabel(value: string | undefined): string | undefined {
|
||||
return value ? `${value} risk` : undefined;
|
||||
}
|
||||
|
||||
function blockedSubject(eventName: string | undefined): string {
|
||||
const event = eventName?.toLowerCase();
|
||||
if (event === "pretooluse" || event === "permissionrequest") return "a tool call";
|
||||
if (event === "userpromptsubmit") return "the prompt";
|
||||
return "the action";
|
||||
}
|
||||
|
||||
function hookPresentation(item: TaskChatProviderActivityItem): GovernancePresentation {
|
||||
const eventName = hookEventLabel(detail(item, "Event"));
|
||||
const subject = eventName ? `${eventName} hook` : "Hook";
|
||||
const tool = detail(item, "Tool Name");
|
||||
const outcome = detail(item, "Outcome") ?? detail(item, "Status");
|
||||
const message = item.summary?.trim() || detail(item, "Message");
|
||||
const source = detail(item, "Source");
|
||||
const duration = durationLabel(detail(item, "Duration Ms"));
|
||||
// The run ended before the hook reported a result.
|
||||
if (item.status === "interrupted") {
|
||||
return { icon: ShieldAlert, label: `${subject} stopped without a result`, detail: joined(tool, source), tone: "neutral" };
|
||||
}
|
||||
if (item.status === "running" || outcome === "running") {
|
||||
return { icon: Webhook, label: eventName ? `Running ${eventName} hook` : "Running a hook", detail: joined(tool, source), tone: "neutral" };
|
||||
}
|
||||
if (outcome === "blocked") {
|
||||
return {
|
||||
icon: ShieldX,
|
||||
label: `${subject} blocked ${tool ?? blockedSubject(eventName)}`,
|
||||
detail: joined(source, duration),
|
||||
tone: "blocked",
|
||||
note: message,
|
||||
};
|
||||
}
|
||||
if (outcome === "stopped") {
|
||||
return { icon: ShieldX, label: `${subject} stopped the turn`, detail: joined(source, duration), tone: "blocked", note: message };
|
||||
}
|
||||
if (outcome === "failed" || item.status === "failed") {
|
||||
return { icon: ShieldAlert, label: `${subject} failed`, detail: joined(source, duration), tone: "failed", note: message };
|
||||
}
|
||||
if (outcome === "feedback") {
|
||||
return { icon: Webhook, label: `${subject} sent feedback`, detail: joined(message, source), tone: "neutral" };
|
||||
}
|
||||
if (outcome === "context") {
|
||||
return { icon: Webhook, label: `${subject} added context`, detail: joined(message, source), tone: "neutral" };
|
||||
}
|
||||
return { icon: Webhook, label: `${subject} ran`, detail: joined(tool, source, duration), tone: "neutral" };
|
||||
}
|
||||
|
||||
function safetyPresentation(item: TaskChatProviderActivityItem): GovernancePresentation {
|
||||
const decision = detail(item, "Decision");
|
||||
const action = detail(item, "Action");
|
||||
const risk = riskLabel(detail(item, "Risk Level"));
|
||||
const rationale = item.summary?.trim() || detail(item, "Rationale");
|
||||
// The run ended before the reviewer reported a decision.
|
||||
if (item.status === "interrupted") {
|
||||
return { icon: ShieldAlert, label: "Auto-review stopped without a decision", detail: action, tone: "neutral" };
|
||||
}
|
||||
if (item.status === "running" || decision === "pending") {
|
||||
return { icon: Shield, label: "Auto-reviewing", detail: action, tone: "neutral" };
|
||||
}
|
||||
if (decision === "allowed") {
|
||||
return { icon: ShieldCheck, label: "Auto-review approved", detail: joined(action, risk), tone: "neutral" };
|
||||
}
|
||||
if (decision === "denied") {
|
||||
return { icon: ShieldX, label: "Auto-review denied", detail: joined(action, risk), tone: "blocked", note: rationale };
|
||||
}
|
||||
if (decision === "timed_out") {
|
||||
return { icon: ShieldAlert, label: "Auto-review timed out", detail: action, tone: "blocked", note: rationale ?? "The action did not run." };
|
||||
}
|
||||
if (decision === "aborted") {
|
||||
return { icon: ShieldAlert, label: "Auto-review stopped", detail: action, tone: "neutral" };
|
||||
}
|
||||
if (decision === "user_required") {
|
||||
return { icon: ShieldAlert, label: "Auto-review asked for approval", detail: joined(action, risk), tone: "neutral", note: rationale };
|
||||
}
|
||||
return { icon: ShieldCheck, label: "Auto-review finished", detail: action, tone: "neutral" };
|
||||
}
|
||||
|
||||
export function governancePresentation(item: TaskChatProviderActivityItem): GovernancePresentation | null {
|
||||
if (item.family === "hook") return hookPresentation(item);
|
||||
if (item.family === "safety") return safetyPresentation(item);
|
||||
return null;
|
||||
}
|
||||
@@ -11,13 +11,11 @@ import {
|
||||
FileCheck2,
|
||||
FilePenLine,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Info,
|
||||
ListChecks,
|
||||
PackageCheck,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
TerminalSquare,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@@ -35,6 +33,7 @@ import {
|
||||
toolActivityPresentation,
|
||||
type ToolIcon,
|
||||
} from "./tool-taxonomy";
|
||||
import { governancePresentation, type GovernanceTone } from "./governance-presentation";
|
||||
|
||||
export interface TaskChatActivityPresentation {
|
||||
icon: ToolIcon;
|
||||
@@ -43,6 +42,9 @@ export interface TaskChatActivityPresentation {
|
||||
failedLabel?: string;
|
||||
interruptedLabel?: string;
|
||||
detail?: string;
|
||||
/** Governance rows tint a stopped action and carry its reason as a note. */
|
||||
tone?: GovernanceTone;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export function providerNoticeSeverity(item: TaskChatProviderActivityItem): "info" | "warning" | "error" {
|
||||
@@ -171,11 +173,21 @@ export function providerActivityPresentation(item: TaskChatProviderActivityItem)
|
||||
: { icon: FileCheck2, runningLabel: "Entering review mode", completedLabel: "Entered review mode", failedLabel: "Couldn’t enter review mode", interruptedLabel: "Review-mode change stopped", detail };
|
||||
}
|
||||
case "hook":
|
||||
return { icon: GitBranch, runningLabel: "Running a hook", completedLabel: "Ran a hook", failedLabel: "Hook failed", interruptedLabel: "Hook stopped", detail };
|
||||
case "safety": {
|
||||
const governance = governancePresentation(item)!;
|
||||
return {
|
||||
icon: governance.icon,
|
||||
runningLabel: governance.label,
|
||||
completedLabel: governance.label,
|
||||
failedLabel: governance.label,
|
||||
interruptedLabel: governance.label,
|
||||
detail: governance.detail,
|
||||
tone: governance.tone,
|
||||
note: governance.note,
|
||||
};
|
||||
}
|
||||
case "memory":
|
||||
return { icon: BookOpen, runningLabel: "Checking memory", completedLabel: "Referenced memory", failedLabel: "Memory lookup failed", interruptedLabel: "Memory lookup stopped", detail };
|
||||
case "safety":
|
||||
return { icon: ShieldCheck, runningLabel: "Reviewing safety", completedLabel: "Reviewed safety", failedLabel: "Safety review failed", interruptedLabel: "Safety review stopped", detail };
|
||||
case "terminal":
|
||||
return { icon: TerminalSquare, runningLabel: "Sending terminal input", completedLabel: "Sent terminal input", failedLabel: "Terminal input failed", interruptedLabel: "Terminal input stopped", detail };
|
||||
case "wait": {
|
||||
|
||||
@@ -208,6 +208,14 @@ export interface TaskChatThinkingItem {
|
||||
transcriptIndex?: number;
|
||||
}
|
||||
|
||||
/** An automatic approval review folded onto the tool row it approved. */
|
||||
export interface TaskChatAutoReview {
|
||||
decision: "allowed";
|
||||
riskLevel?: string;
|
||||
userConsent?: string;
|
||||
rationale?: string;
|
||||
}
|
||||
|
||||
/** A tool invocation row (ACP tool_call / tool_call_update). */
|
||||
export interface TaskChatToolItem {
|
||||
id: string;
|
||||
@@ -227,6 +235,8 @@ export interface TaskChatToolItem {
|
||||
diff?: TaskChatDiff;
|
||||
/** Resolved permission decision badge, when one applied. */
|
||||
decision?: "allowed" | "rejected";
|
||||
/** Automatic approval review that let this call run. */
|
||||
autoReview?: TaskChatAutoReview;
|
||||
/** Canonical operation (read, search, list, execute, edit) when known. */
|
||||
operation?: string;
|
||||
/** Provider's short description of the call, such as Claude's Bash description. */
|
||||
@@ -428,6 +438,8 @@ export interface TaskChatProviderActivityItem {
|
||||
resolved?: boolean;
|
||||
/** When the provider first reported this activity (ISO timestamp). */
|
||||
startedAt?: string;
|
||||
/** Automatic approval review that let this tool execution run. */
|
||||
autoReview?: TaskChatAutoReview;
|
||||
}
|
||||
|
||||
export type TaskChatSubagentStatus = "running" | "completed" | "failed" | "cancelled";
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* thinking → tool → diff → responding while a run is in flight, rather than
|
||||
* only showing a response after the turn settles.
|
||||
*/
|
||||
import { foldApprovedAutoReviews } from "./fold-auto-reviews";
|
||||
import { isFatalProviderError, isStatusNotice, isStatusNoticePayload, statusNoticeCategory } from "./status-notice-presentation";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import { turnChangesFromEditRows } from "./turn-file-changes";
|
||||
@@ -317,9 +318,17 @@ const PROVIDER_DETAIL_KEYS: Record<
|
||||
"failure",
|
||||
],
|
||||
review: ["state", "scope"],
|
||||
hook: ["event", "scope", "status", "blocking", "durationMs", "summary"],
|
||||
hook: ["event", "toolName", "outcome", "status", "source", "durationMs", "blocking", "scope"],
|
||||
memory: ["label", "available", "reference"],
|
||||
safety: ["status", "decision", "targetExecutionId", "summary"],
|
||||
safety: [
|
||||
"decision",
|
||||
"actionKind",
|
||||
"action",
|
||||
"riskLevel",
|
||||
"userConsent",
|
||||
"decidedBy",
|
||||
"targetExecutionId",
|
||||
],
|
||||
terminal: ["origin", "inputClass", "byteCount"],
|
||||
wait: ["reason", "status", "plannedDurationMs", "elapsedDurationMs"],
|
||||
verification: ["status", "detail", "reasonCode"],
|
||||
@@ -386,7 +395,8 @@ function providerActivityItem(
|
||||
(entry.family === "provider_notice" && (key === "summary" || key === "message")) ||
|
||||
(entry.family === "verification" && key === "detail")
|
||||
? 4000
|
||||
: key === "message" || key === "summary" || key === "reason" ? 320 : 160,
|
||||
: key === "message" || key === "summary" || key === "reason" ? 320
|
||||
: entry.family === "safety" && key === "action" ? 1000 : 160,
|
||||
),
|
||||
mono: /(?:id|model|target|reference|url|code|bytes)$/i.test(key),
|
||||
});
|
||||
@@ -976,7 +986,16 @@ export function transcriptToTaskChatItems(
|
||||
: `provider:${providerActivityKey(entry)}`;
|
||||
const item = providerActivityItem(entry, runId, i);
|
||||
const existingIndex = protocolIndexByKey.get(key);
|
||||
if (existingIndex == null) {
|
||||
const previous = existingIndex == null ? undefined : items[existingIndex];
|
||||
// A harness can reuse one hook id for two calls (for example two
|
||||
// calls in the same second). Once a hook row has finished, a later
|
||||
// record with that id is a new call: keep both rows, so a later pass
|
||||
// can never hide an earlier block reason.
|
||||
const newHookCall = entry.family === "hook"
|
||||
&& previous?.kind === "protocol"
|
||||
&& previous.surface === "provider_activity"
|
||||
&& previous.status !== "running";
|
||||
if (existingIndex == null || newHookCall) {
|
||||
items.push(item);
|
||||
protocolIndexByKey.set(key, items.length - 1);
|
||||
} else {
|
||||
@@ -1203,17 +1222,18 @@ export function transcriptToTaskChatItems(
|
||||
// Every turn that edited files ends with its changed files, just before its
|
||||
// final answer. A harness that reports only per-edit patches gets the card
|
||||
// assembled from its edit rows.
|
||||
const changeIndex = items.findIndex((item) => item.kind === "protocol" && item.surface === "workspace_change");
|
||||
const folded = foldApprovedAutoReviews(items, runId);
|
||||
const changeIndex = folded.findIndex((item) => item.kind === "protocol" && item.surface === "workspace_change");
|
||||
const changes = changeIndex >= 0
|
||||
? items.splice(changeIndex, 1)[0]
|
||||
: turnChangesFromEditRows(items, runId ?? "run", !running);
|
||||
? folded.splice(changeIndex, 1)[0]
|
||||
: turnChangesFromEditRows(folded, runId ?? "run", !running);
|
||||
if (changes) {
|
||||
const last = items.at(-1);
|
||||
const last = folded.at(-1);
|
||||
const beforeFinal = last?.kind === "message" && last.channel === "final";
|
||||
items.splice(beforeFinal ? items.length - 1 : items.length, 0, changes);
|
||||
folded.splice(beforeFinal ? folded.length - 1 : folded.length, 0, changes);
|
||||
}
|
||||
|
||||
return items;
|
||||
return folded;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HeartbeatRunEvent } from "@paperclipai/shared";
|
||||
import { nativeRunEventsToTranscript } from "./native-run-events";
|
||||
import { transcriptToTaskChatItems } from "../task-chat/transcript-adapter";
|
||||
import { protocolActivityLabel, protocolActivityPresentation } from "../task-chat/task-chat-activity-presentation";
|
||||
import type { TaskChatItem, TaskChatProviderActivityItem } from "../task-chat/task-chat-model";
|
||||
|
||||
const RUN_ID = "10000000-0000-4000-8000-000000000001";
|
||||
|
||||
function event(seq: number, eventType: string, payload: Record<string, unknown>): HeartbeatRunEvent {
|
||||
return {
|
||||
id: seq,
|
||||
companyId: "10000000-0000-4000-8000-000000000002",
|
||||
runId: RUN_ID,
|
||||
agentId: "10000000-0000-4000-8000-000000000003",
|
||||
seq,
|
||||
eventType,
|
||||
stream: "system",
|
||||
level: "info",
|
||||
color: null,
|
||||
message: null,
|
||||
payload: {
|
||||
prpEvent: {
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: `event-${seq}`,
|
||||
sourceSeq: seq,
|
||||
sourceInstanceId: "runner-1",
|
||||
sourceKind: "runner",
|
||||
runId: RUN_ID,
|
||||
normalizedSessionId: "session-1",
|
||||
eventType,
|
||||
schemaVersion: 1,
|
||||
priority: 1,
|
||||
emittedAt: `2026-11-05T18:00:${String(seq).padStart(2, "0")}.000Z`,
|
||||
payload,
|
||||
},
|
||||
},
|
||||
createdAt: new Date("2026-11-05T18:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function review(seq: number, reviewId: string, target: string | null, decision: string, extra: Record<string, unknown> = {}) {
|
||||
return event(seq, decision === "pending" ? "safety.review.started" : "safety.review.completed", {
|
||||
schema: "paperclip.safety.review.v1",
|
||||
reviewId,
|
||||
targetExecutionId: target,
|
||||
status: decision === "pending" ? "running" : "completed",
|
||||
decision,
|
||||
summary: null,
|
||||
actionKind: "command",
|
||||
action: "rm -rf build/",
|
||||
riskLevel: decision === "pending" ? null : "low",
|
||||
userAuthorization: null,
|
||||
rationale: null,
|
||||
decidedBy: decision === "pending" ? null : "agent",
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function command(seq: number, executionId: string, name: string, status: "running" | "completed") {
|
||||
return event(seq, status === "running" ? "tool.execution.started" : "tool.execution.completed", {
|
||||
schema: "paperclip.tool.execution.v1",
|
||||
executionId,
|
||||
transport: "process",
|
||||
operation: "execute",
|
||||
name,
|
||||
status,
|
||||
output: null,
|
||||
outputBytes: 0,
|
||||
outputTruncated: false,
|
||||
outputDigest: null,
|
||||
});
|
||||
}
|
||||
|
||||
function hook(seq: number, payload: Record<string, unknown>) {
|
||||
return event(seq, "hook.completed", {
|
||||
schema: "paperclip.hook.v1",
|
||||
hookId: "pre-tool-use-0",
|
||||
event: "preToolUse",
|
||||
scope: "turn",
|
||||
status: "completed",
|
||||
blocking: true,
|
||||
durationMs: 41,
|
||||
summary: null,
|
||||
outcome: "passed",
|
||||
message: null,
|
||||
source: "user hooks",
|
||||
toolName: null,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
|
||||
function items(events: HeartbeatRunEvent[]): TaskChatItem[] {
|
||||
return transcriptToTaskChatItems(nativeRunEventsToTranscript(events), { runId: RUN_ID, agentName: "Codex", running: false });
|
||||
}
|
||||
|
||||
function governance(list: TaskChatItem[]): TaskChatProviderActivityItem[] {
|
||||
return list.filter((item): item is TaskChatProviderActivityItem =>
|
||||
item.kind === "protocol" && item.surface === "provider_activity" && (item.family === "hook" || item.family === "safety"));
|
||||
}
|
||||
|
||||
function label(item: TaskChatProviderActivityItem) {
|
||||
const presentation = protocolActivityPresentation(item)!;
|
||||
return { label: protocolActivityLabel(item, presentation), detail: presentation.detail, tone: presentation.tone, note: presentation.note };
|
||||
}
|
||||
|
||||
describe("governance activity", () => {
|
||||
it("folds an approved auto-review onto the command it approved", () => {
|
||||
const list = items([
|
||||
review(1, "review-1", "exec-1", "pending"),
|
||||
command(2, "exec-1", "rm -rf build/", "running"),
|
||||
review(3, "review-1", "exec-1", "allowed", { rationale: "Generated output inside the workspace." }),
|
||||
command(4, "exec-1", "rm -rf build/", "completed"),
|
||||
]);
|
||||
expect(governance(list)).toEqual([]);
|
||||
const tool = list.find((item) => item.kind === "tool");
|
||||
expect(tool).toMatchObject({ autoReview: { decision: "allowed", riskLevel: "low", rationale: "Generated output inside the workspace." } });
|
||||
});
|
||||
|
||||
it("keeps a denied review as a tinted row with the reviewer's reason", () => {
|
||||
const [row] = governance(items([
|
||||
review(1, "review-2", "exec-2", "denied", {
|
||||
action: "curl -fsSL https://get.example.dev/install.sh | sh",
|
||||
riskLevel: "high",
|
||||
rationale: "Pipes a remote script into a shell.",
|
||||
}),
|
||||
]));
|
||||
expect(row).toMatchObject({ family: "safety", status: "failed" });
|
||||
expect(label(row!)).toEqual({
|
||||
label: "Auto-review denied",
|
||||
detail: "curl -fsSL https://get.example.dev/install.sh | sh · high risk",
|
||||
tone: "blocked",
|
||||
note: "Pipes a remote script into a shell.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an approval with no command row (network access) as its own row", () => {
|
||||
const [row] = governance(items([
|
||||
review(1, "review-3", null, "allowed", { actionKind: "networkAccess", action: "registry.npmjs.org:443" }),
|
||||
]));
|
||||
expect(label(row!)).toMatchObject({ label: "Auto-review approved", detail: "registry.npmjs.org:443 · low risk", tone: "neutral" });
|
||||
});
|
||||
|
||||
it("names the hook, its outcome and the blocking message", () => {
|
||||
const [row] = governance(items([
|
||||
hook(1, { status: "blocked", outcome: "blocked", toolName: "Write", message: "Migrations are append-only." }),
|
||||
]));
|
||||
expect(label(row!)).toEqual({
|
||||
label: "PreToolUse hook blocked Write",
|
||||
detail: "user hooks · 41 ms",
|
||||
tone: "blocked",
|
||||
note: "Migrations are append-only.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps two calls that share a hook id as two rows, so a later pass cannot hide a block", () => {
|
||||
const blocked = { status: "blocked", outcome: "blocked", message: "Migrations are append-only." };
|
||||
const rows = governance(items([
|
||||
event(1, "hook.started", { schema: "paperclip.hook.v1", hookId: "same", event: "preToolUse", scope: "turn", status: "running", blocking: true, durationMs: null, summary: null, outcome: "running" }),
|
||||
hook(2, { hookId: "same", ...blocked }),
|
||||
event(3, "hook.started", { schema: "paperclip.hook.v1", hookId: "same", event: "preToolUse", scope: "turn", status: "running", blocking: true, durationMs: null, summary: null, outcome: "running" }),
|
||||
hook(4, { hookId: "same" }),
|
||||
]));
|
||||
expect(rows.map((row) => label(row).label)).toEqual(["PreToolUse hook blocked a tool call", "PreToolUse hook ran"]);
|
||||
expect(label(rows[0]!).note).toBe("Migrations are append-only.");
|
||||
});
|
||||
|
||||
it("says a hook or review stopped when the run ended before it finished", () => {
|
||||
const settled = transcriptToTaskChatItems(nativeRunEventsToTranscript([
|
||||
event(1, "hook.started", { schema: "paperclip.hook.v1", hookId: "h", event: "preToolUse", scope: "turn", status: "running", blocking: true, durationMs: null, summary: null, outcome: "running" }),
|
||||
review(2, "review-x", "exec-x", "pending"),
|
||||
]), { runId: RUN_ID, agentName: "Codex", running: false });
|
||||
const rows = governance(settled);
|
||||
expect(rows.map((row) => row.status)).toEqual(["interrupted", "interrupted"]);
|
||||
expect(rows.map((row) => label(row).label)).toEqual([
|
||||
"PreToolUse hook stopped without a result",
|
||||
"Auto-review stopped without a decision",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a passing hook to one quiet line", () => {
|
||||
const [row] = governance(items([hook(1, {})]));
|
||||
expect(label(row!)).toEqual({ label: "PreToolUse hook ran", detail: "user hooks · 41 ms", tone: "neutral", note: undefined });
|
||||
});
|
||||
|
||||
it("reads hook records written before outcome existed", () => {
|
||||
const [row] = governance(items([event(1, "hook.completed", {
|
||||
schema: "paperclip.hook.v1",
|
||||
hookId: "legacy",
|
||||
event: "post-tool",
|
||||
scope: "workspace",
|
||||
status: "failed",
|
||||
blocking: false,
|
||||
durationMs: null,
|
||||
summary: "script exited 1",
|
||||
})]));
|
||||
expect(label(row!)).toMatchObject({ label: "Post-tool hook failed", tone: "failed", note: "script exited 1" });
|
||||
});
|
||||
});
|
||||
@@ -998,16 +998,17 @@ describe("nativeRunEventsToTranscript", () => {
|
||||
|
||||
it("leaves families owned by other surfaces on the generic activity path", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
event(1, "hook.completed", {
|
||||
schema: "paperclip.hook.v1",
|
||||
hookId: "hook-1",
|
||||
event: "PreToolUse",
|
||||
status: "completed",
|
||||
summary: "lint hook",
|
||||
event(1, "memory.citation.referenced", {
|
||||
schema: "paperclip.memory.citation.v1",
|
||||
citationId: "citation-1",
|
||||
messageItemId: "message-1",
|
||||
label: "Decision log",
|
||||
available: false,
|
||||
reference: null,
|
||||
}),
|
||||
])).toEqual([
|
||||
expect.objectContaining({ kind: "tool_call", toolUseId: "hook:hook-1" }),
|
||||
expect.objectContaining({ kind: "tool_result", toolUseId: "hook:hook-1" }),
|
||||
expect.objectContaining({ kind: "tool_call", toolUseId: "memory:citation-1" }),
|
||||
expect.objectContaining({ kind: "tool_result", toolUseId: "memory:citation-1" }),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1032,6 +1032,40 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
continue;
|
||||
}
|
||||
|
||||
// Governance records (hooks, automatic approval reviews) keep their full
|
||||
// payload so the task chat can show the decision, reason and target.
|
||||
if ((event.eventType === "hook.started" || event.eventType === "hook.completed")
|
||||
&& payload.schema === "paperclip.hook.v1" && text(payload.hookId)) {
|
||||
const outcome = text(payload.outcome) ?? text(payload.status);
|
||||
entries.push({
|
||||
kind: "provider_activity",
|
||||
ts,
|
||||
family: "hook",
|
||||
eventType: event.eventType,
|
||||
status: outcome === "running" ? "running" : outcome === "failed" ? "failed" : "completed",
|
||||
title: "Hook",
|
||||
summary: text(payload.message)?.trim() || text(payload.summary)?.trim() || "",
|
||||
payload,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if ((event.eventType === "safety.review.started" || event.eventType === "safety.review.completed")
|
||||
&& payload.schema === "paperclip.safety.review.v1" && text(payload.reviewId)) {
|
||||
const decision = text(payload.decision);
|
||||
entries.push({
|
||||
kind: "provider_activity",
|
||||
ts,
|
||||
family: "safety",
|
||||
eventType: event.eventType,
|
||||
status: decision === "pending" || payload.status === "running"
|
||||
? "running"
|
||||
: decision === "denied" || decision === "timed_out" ? "failed" : "completed",
|
||||
title: "Auto-review",
|
||||
summary: text(payload.rationale)?.trim() || text(payload.summary)?.trim() || "",
|
||||
payload,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A delegation carries its children (subagent type, prompt, progress,
|
||||
// result) to the task-chat subagent card, so keep the full snapshot.
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TaskChatRunnerActivityGroup } from "@/components/task-chat/TaskChatRunnerActivityGroup";
|
||||
import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
|
||||
import { foldApprovedAutoReviews } from "@/components/task-chat/fold-auto-reviews";
|
||||
import type {
|
||||
TaskChatActivityPhaseItem,
|
||||
TaskChatItem,
|
||||
TaskChatProtocolDetail,
|
||||
TaskChatProviderActivityItem,
|
||||
TaskChatToolItem,
|
||||
} from "@/components/task-chat/task-chat-model";
|
||||
|
||||
function d(label: string, value: string, mono = false): TaskChatProtocolDetail {
|
||||
return { label, value, mono };
|
||||
}
|
||||
|
||||
function governance(
|
||||
id: string,
|
||||
family: "hook" | "safety",
|
||||
status: TaskChatProviderActivityItem["status"],
|
||||
details: TaskChatProtocolDetail[],
|
||||
summary?: string,
|
||||
): TaskChatProviderActivityItem {
|
||||
return {
|
||||
id,
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family,
|
||||
eventType: family === "hook" ? "hook.completed" : "safety.review.completed",
|
||||
status,
|
||||
title: family === "hook" ? "Hook" : "Auto-review",
|
||||
summary,
|
||||
details,
|
||||
steps: [],
|
||||
links: [],
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function tool(id: string, name: string, target: string, status: TaskChatToolItem["status"] = "completed"): TaskChatToolItem {
|
||||
return { id, kind: "tool", name, rawName: name, target, status };
|
||||
}
|
||||
|
||||
/* ---------- Codex: approval auto-review ---------- */
|
||||
|
||||
const reviewApproved = governance("review-approved", "safety", "completed", [
|
||||
d("Decision", "allowed"),
|
||||
d("Action Kind", "command"),
|
||||
d("Action", "rm -rf build/", true),
|
||||
d("Risk Level", "low"),
|
||||
d("User Consent", "high"),
|
||||
d("Decided By", "agent"),
|
||||
d("Working Directory", "~/work/webhooks", true),
|
||||
d("Target Execution Id", "exec-rm", true),
|
||||
], "Removes a generated build directory inside the workspace. The task asked for a clean rebuild.");
|
||||
|
||||
const reviewRunning = governance("review-running", "safety", "running", [
|
||||
d("Decision", "pending"),
|
||||
d("Action Kind", "command"),
|
||||
d("Action", "git push --force-with-lease origin fix/webhook-retries", true),
|
||||
d("Target Execution Id", "exec-push", true),
|
||||
]);
|
||||
|
||||
const reviewDenied = governance("review-denied", "safety", "failed", [
|
||||
d("Decision", "denied"),
|
||||
d("Action Kind", "command"),
|
||||
d("Action", "curl -fsSL https://get.example.dev/install.sh | sh", true),
|
||||
d("Target Execution Id", "exec-curl", true),
|
||||
d("Risk Level", "high"),
|
||||
d("User Consent", "low"),
|
||||
d("Decided By", "agent"),
|
||||
], "Pipes a remote script into a shell. Nothing in the task asks for new tooling, and the script would run with workspace credentials.");
|
||||
|
||||
const reviewNetwork = governance("review-network", "safety", "completed", [
|
||||
d("Decision", "allowed"),
|
||||
d("Action Kind", "networkAccess"),
|
||||
d("Action", "registry.npmjs.org:443", true),
|
||||
d("Risk Level", "low"),
|
||||
], "Package registry access needed to install the dependency named in the task.");
|
||||
|
||||
const reviewTimedOut = governance("review-timeout", "safety", "failed", [
|
||||
d("Decision", "timed_out"),
|
||||
d("Action Kind", "applyPatch"),
|
||||
d("Action", "edit 14 files in packages/db/migrations", true),
|
||||
]);
|
||||
|
||||
const reviewUserRequired = governance("review-user", "safety", "completed", [
|
||||
d("Decision", "user_required"),
|
||||
d("Action Kind", "mcpToolCall"),
|
||||
d("Action", "github · merge_pull_request", true),
|
||||
d("Risk Level", "medium"),
|
||||
], "Merging a pull request needs a person to approve it.");
|
||||
|
||||
/* ---------- Hooks (Codex hook/* and Claude SDK hook messages) ---------- */
|
||||
|
||||
const hookRan = governance("hook-ran", "hook", "completed", [
|
||||
d("Event", "PreToolUse"),
|
||||
d("Tool Name", "Bash"),
|
||||
d("Outcome", "completed"),
|
||||
d("Duration Ms", "84"),
|
||||
]);
|
||||
|
||||
const hookBlocked = governance("hook-blocked", "hook", "completed", [
|
||||
d("Event", "PreToolUse"),
|
||||
d("Tool Name", "Write"),
|
||||
d("Outcome", "blocked"),
|
||||
], "Migrations are append-only. Add a new migration instead of editing 0042_webhook_events.sql.");
|
||||
|
||||
const hookContext = governance("hook-context", "hook", "completed", [
|
||||
d("Event", "UserPromptSubmit"),
|
||||
d("Outcome", "context"),
|
||||
d("Source", "user hooks", true),
|
||||
d("Duration Ms", "112"),
|
||||
], "Added on-call context: staging is frozen until 17:00 UTC");
|
||||
|
||||
const hookFeedback = governance("hook-feedback", "hook", "completed", [
|
||||
d("Event", "Stop"),
|
||||
d("Outcome", "feedback"),
|
||||
], "Tests have not run since the last edit. Run pnpm test before finishing.");
|
||||
|
||||
const hookFailed = governance("hook-failed", "hook", "failed", [
|
||||
d("Event", "sessionStart"),
|
||||
d("Outcome", "failed"),
|
||||
d("Source", "project hooks", true),
|
||||
d("Duration Ms", "3020"),
|
||||
], "load-env.sh: line 4: op: command not found");
|
||||
|
||||
const hookRunning = governance("hook-running", "hook", "running", [
|
||||
d("Event", "PostToolUse"),
|
||||
d("Tool Name", "Edit"),
|
||||
d("Outcome", "running"),
|
||||
]);
|
||||
|
||||
const hookStopped = governance("hook-stopped", "hook", "completed", [
|
||||
d("Event", "PreToolUse"),
|
||||
d("Tool Name", "Bash"),
|
||||
d("Outcome", "stopped"),
|
||||
], "Run budget reached 95%. Stopping before the deploy step.");
|
||||
|
||||
/* ---------- Realistic runs ---------- */
|
||||
|
||||
function phase(id: string, items: TaskChatActivityPhaseItem["items"], active = false): TaskChatActivityPhaseItem {
|
||||
return { id, kind: "activity_phase", items, summary: "", active };
|
||||
}
|
||||
|
||||
const CODEX_RUN = "storybook-codex-run";
|
||||
|
||||
function codexTool(executionId: string, name: string, target: string, status: TaskChatToolItem["status"] = "completed"): TaskChatToolItem {
|
||||
return { id: `${CODEX_RUN}:tool:${executionId}`, kind: "tool", name, rawName: name, target, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw adapter order: each review arrives just before the command it gates.
|
||||
* The fold turns approvals into a badge on that command; the denial (whose
|
||||
* command never ran) and the network review (no tool row) stay as rows.
|
||||
*/
|
||||
const codexRawItems: TaskChatItem[] = [
|
||||
codexTool("exec-read", "read_file", "src/delivery/worker.ts"),
|
||||
codexTool("exec-rg", "shell", "rg -n \"retryDelivery\" src"),
|
||||
reviewApproved,
|
||||
codexTool("exec-rm", "shell", "rm -rf build/"),
|
||||
reviewDenied,
|
||||
codexTool("exec-add", "shell", "pnpm add -D tsx"),
|
||||
reviewNetwork,
|
||||
codexTool("exec-patch", "apply_patch", "src/delivery/worker.ts"),
|
||||
codexTool("exec-test", "shell", "pnpm test src/delivery"),
|
||||
];
|
||||
|
||||
const codexRunActivity = phase(
|
||||
"codex-phase",
|
||||
foldApprovedAutoReviews(codexRawItems.map((item) => ({ ...item })), CODEX_RUN) as TaskChatActivityPhaseItem["items"],
|
||||
);
|
||||
|
||||
const claudeRunActivity = phase("claude-phase", [
|
||||
hookContext,
|
||||
tool("c1", "Read", "packages/db/migrations/0042_webhook_events.sql"),
|
||||
hookBlocked,
|
||||
hookRan,
|
||||
tool("c2", "Bash", "pnpm db:generate"),
|
||||
tool("c3", "Write", "packages/db/migrations/0043_webhook_event_dedupe.sql"),
|
||||
hookFeedback,
|
||||
tool("c4", "Bash", "pnpm test"),
|
||||
]);
|
||||
|
||||
const liveRunItems: TaskChatItem[] = foldApprovedAutoReviews([
|
||||
codexTool("exec-read", "read_file", "src/delivery/worker.ts"),
|
||||
{ ...reviewApproved },
|
||||
codexTool("exec-rm", "shell", "rm -rf build/"),
|
||||
{ ...reviewRunning },
|
||||
], CODEX_RUN);
|
||||
|
||||
function Page({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-10">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, rule, children }: { title: string; rule: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{rule}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-background p-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedApproval() {
|
||||
const [rm] = foldApprovedAutoReviews([{ ...reviewApproved }, codexTool("exec-rm", "shell", "rm -rf build/")], CODEX_RUN);
|
||||
return (
|
||||
<TaskChatRunnerActivityGroup item={phase("expanded-approval", [rm as TaskChatToolItem])} defaultExpanded />
|
||||
);
|
||||
}
|
||||
|
||||
function Gallery() {
|
||||
return (
|
||||
<Page>
|
||||
<header>
|
||||
<h2 className="text-lg font-semibold">Governance rows</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatic approval reviews (Codex auto-review) and harness hooks (Codex and Claude) render as small grey rows
|
||||
among the run's other activity, so decisions nobody clicked are still auditable. A denied or blocked action
|
||||
is tinted and keeps its reason visible. Everything else expands to the full record.
|
||||
</p>
|
||||
</header>
|
||||
<Section title="Codex run with auto-review" rule="An approval is folded into the command it approved as a small “auto-approved” mark; expand the command to read the review. A denial keeps its own row with the reviewer's reason.">
|
||||
<TaskChatRunnerActivityGroup item={codexRunActivity} defaultExpanded />
|
||||
</Section>
|
||||
<Section title="Approved command, expanded" rule="The review record sits under the command's own details.">
|
||||
<ExpandedApproval />
|
||||
</Section>
|
||||
<Section title="Claude run with hooks" rule="Hook rows name the event and, for Claude, the tool. A blocking hook shows its message. Quiet hooks that just passed stay one short line.">
|
||||
<TaskChatRunnerActivityGroup item={claudeRunActivity} defaultExpanded />
|
||||
</Section>
|
||||
<Section title="Every auto-review outcome" rule="Standalone rows: pending · approved without a tool row (network access) · denied · timed out · asked for a person">
|
||||
<TaskChatRunnerActivityGroup
|
||||
item={phase("review-states", [reviewRunning, reviewNetwork, reviewDenied, reviewTimedOut, reviewUserRequired], true)}
|
||||
defaultExpanded
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Every hook outcome" rule="running · ran · added context · sent feedback · blocked · stopped the turn · failed">
|
||||
<TaskChatRunnerActivityGroup
|
||||
item={phase("hook-states", [hookRunning, hookRan, hookContext, hookFeedback, hookBlocked, hookStopped, hookFailed], true)}
|
||||
defaultExpanded
|
||||
/>
|
||||
</Section>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveTurn() {
|
||||
return (
|
||||
<Page>
|
||||
<Section title="Live turn, collapsed" rule="While the run is working, the rolling activity line shows the review in progress.">
|
||||
<TaskChatRunnerTurn
|
||||
runId="storybook-governance-live"
|
||||
agentName="Codex"
|
||||
items={liveRunItems}
|
||||
status="running"
|
||||
startedAtMs={Date.now() - 42_000}
|
||||
/>
|
||||
</Section>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
id: "product-task-chat-governance-rows",
|
||||
title: "Tasks and conversations/Task creation and lifecycle/Product/Task chat/Governance rows",
|
||||
component: Gallery,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Auto-review decisions and hook outcomes as small grey activity rows. Built from canonical `safety.review.*` and `hook.*` provider events.",
|
||||
},
|
||||
},
|
||||
paperclip: {
|
||||
"capabilities": [
|
||||
"tasks/discussion",
|
||||
"runs-adapters/runs"
|
||||
],
|
||||
"status": "current",
|
||||
"implementation": "production",
|
||||
"boundary": "fixtures",
|
||||
"notes": "Renders the shipped runner activity group with fixture governance items, folding approvals through the production foldApprovedAutoReviews helper. Provider event mapping and live hook delivery are covered by runner tests, not this story."
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Gallery>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const AllOutcomes: Story = {};
|
||||
export const LiveTurnCollapsed: Story = { render: () => <LiveTurn /> };
|
||||
Reference in new issue
Block a user