Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ uphold guard --stage pre-push # the guards for that Git hook
uphold shim gh pr create ... # stand in front of a command, then exec
uphold shim --install # link this binary under each command's name
uphold shim --status # what is linked, and whether PATH reaches it
uphold hook claude-code # judge a pending agent tool call, read on stdin
uphold hook claude-code # judge a pending tool call or a turn's end, read on stdin
uphold hook codex # the same checks through Codex's PreToolUse hook
uphold audit --for-publication # before flipping private -> public
uphold supply-chain # scanners and the git-pin check over the pushed range; a missing one is exit 2
Expand Down Expand Up @@ -348,6 +348,11 @@ publish your text; the example covers the GitHub MCP server. See
[Codex hooks](https://learn.chatgpt.com/docs/hooks) and the
[configuration reference](docs/REFERENCE.md#configuring-it) for inline TOML.

The same command, configured for `SessionStart` and `Stop`, keeps an agent from
ending its turn with the gate red: `[stop] run` names the commands, and a turn
is blocked only on one that was green when the session started. See
[the end of a turn](docs/REFERENCE.md#the-end-of-a-turn-stop).

**`uphold hooks --identity DIR...`** and **`uphold probe`** answer two questions
a single repository cannot answer about itself. A forked hook declaration is
valid in every tree that holds it, so only a comparison across repositories
Expand Down
64 changes: 64 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3247,6 +3247,70 @@ report goes, and which exit code that harness reads as blocked. A name the table
does not carry is refused and the known ones are listed, because none of those
five values is derivable from a name.

### The end of a turn: `[stop]`

An agent can finish, say it is done, and leave a tree the repository's own gate
refuses; the refusal then arrives at the next commit, often in another session.
The same `uphold hook claude-code` entry, configured for `SessionStart` and
`Stop`, holds the end of a turn to the commands a repository names:

```toml
# policy/principles.toml
[stop]
run = [["uphold", "check"], ["cargo", "test", "--locked", "--quiet"]]
```

Each entry is one command as argv, run from the repository root with no shell.
A command that needs a pipeline names a script holding it. An entry with no
program is refused at load, and so is `[stop]` in a file read through
`[inherit]`: what a tree has to pass is that repository's own declaration.

```jsonc
// .claude/settings.json
{"hooks": {
"SessionStart": [{"hooks": [{"type": "command", "command": "uphold hook claude-code", "async": true}]}],
"Stop": [{"hooks": [{"type": "command", "command": "uphold hook claude-code", "timeout": 900}]}]
}}
```

`hook_event_name` says which arrived. Every other event, and an event naming
none, is judged as a pending tool call, as above.

- **`SessionStart`** runs every command once and records which failed, under
the worktree's git dir (`.git/uphold/stop/<session_id>.json`). It prints
nothing on stdout, because Claude Code adds that to the conversation, and
`async` keeps the session from waiting for it. Because the agent is already
working while it runs, the tree is fingerprinted before and after (HEAD, and
the size and modification time of every changed or untracked path); a
baseline whose tree moved is discarded, so every red command blocks. A gate
that writes outside ignored paths therefore never leaves a usable baseline.
Claude Code fires `SessionStart` again on resume and compaction under the
same id; the first baseline, finished or not, is kept. A `session_id` outside
`A-Z a-z 0-9 - _`, or longer than 128, is refused rather than used as a
file name.
- **`Stop`** runs the commands again. One that fails now and passed at session
start blocks the turn with `{"decision": "block", "reason": ...}`, where the
reason names the command and the last 40 lines of its output. One that was
already failing does not block, and is named in `systemMessage` for the
person. With no baseline, because `SessionStart` is not configured or the
id was unusable, every failing command blocks and the reason says so. A
baseline still being written is waited for, up to 300 seconds from when it
started (a killed one is not waited on again), so the
`timeout` above covers that wait plus one run of the gate.

A turn's end is blocked once. When `stop_hook_active` says this stop follows
a block, a gate still red is named in `systemMessage` and the turn ends: the
reason told the agent to fix it or say it cannot, and blocking an agent that
said so would spend Claude Code's own cap (eight consecutive continuations,
reset by a tool call) on a turn that is over. A command that cannot be started
is exit `2`; at `Stop`
that sends stderr to the agent as the reason, so a broken gate fails closed,
once: on a stop that follows a block it is named in `systemMessage` instead.
With no policy, or no `[stop]`, nothing runs, and stderr says no gate was
checked rather than that one passed. `--junit` is not written for these two
events. The gate judges the session's working directory, so work done in a
separate `git worktree` is checked only if the session runs there.

## `uphold audit --for-publication`

Every guard across every seam conditions on *is the target public **now***. So
Expand Down
22 changes: 22 additions & 0 deletions docs/schema/principles.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,24 @@
],
"type": "object"
},
"Stop": {
"additionalProperties": false,
"description": "`[stop]` in a repository's own policy: the gate `uphold hook` runs when an\nagent ends its turn.\n\nargv arrays and not a shell string, so what runs is what a reader sees:\nno quoting, no expansion, no second language inside the policy file\n(`least-power`). A command that needs a pipeline names a script that holds it.",
"properties": {
"run": {
"default": [],
"description": "Each entry is one command as argv, run in order from the repository\nroot, with no shell. Every one runs, red or green, so the report names\nall that failed rather than the first.",
"items": {
"items": {
"type": "string"
},
"type": "array"
},
"type": "array"
}
},
"type": "object"
},
"SupplyChain": {
"additionalProperties": false,
"description": "`[supply_chain]` in a repository's own policy.",
Expand Down Expand Up @@ -893,6 +911,10 @@
},
"type": "array"
},
"stop": {
"$ref": "#/$defs/Stop",
"description": "`[stop]`: the gate an agent's turn may not end red. See [`Stop`]."
},
"supply_chain": {
"$ref": "#/$defs/SupplyChain",
"description": "`[supply_chain]`: what this repository has said about its own\ndependency scan. See [`SupplyChain`]."
Expand Down
54 changes: 54 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,39 @@ pub(crate) struct SupplyChain {
pub forge_host: Option<String>,
}

/// `[stop]` in a repository's own policy: the gate `uphold hook` runs when an
/// agent ends its turn.
///
/// argv arrays and not a shell string, so what runs is what a reader sees:
/// no quoting, no expansion, no second language inside the policy file
/// (`least-power`). A command that needs a pipeline names a script that holds it.
#[derive(Debug, Clone, Default, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct Stop {
/// Each entry is one command as argv, run in order from the repository
/// root, with no shell. Every one runs, red or green, so the report names
/// all that failed rather than the first.
#[serde(default)]
pub run: Vec<Vec<String>>,
}

impl Stop {
/// An empty argv, or one whose program is empty, cannot be spawned, and a
/// gate holding one would refuse every turn's end for a reason that is the
/// policy file's rather than the tree's. Refused at load instead.
fn validate(&self) -> std::result::Result<(), String> {
for argv in &self.run {
if argv.first().is_none_or(|program| program.trim().is_empty()) {
return Err(format!(
"`[stop] run` holds {argv:?}, which names no program. Each entry is one \
command as argv, its program first"
));
}
}
Ok(())
}
}

/// The scanner a retired waiver may still name. uphold no longer runs it, so
/// the row is read and ignored; naming any other scanner is an error, as it
/// always was, because every scanner that is run carries its own suppression in
Expand Down Expand Up @@ -949,6 +982,9 @@ pub(crate) struct PolicyFile {
/// dependency scan. See [`SupplyChain`].
#[serde(default)]
pub supply_chain: Option<SupplyChain>,
/// `[stop]`: the gate an agent's turn may not end red. See [`Stop`].
#[serde(default)]
pub stop: Option<Stop>,
/// Whether every path-baseline entry must say who excused it and why.
///
/// Off by default, and the default is not neutrality -- it is what every
Expand Down Expand Up @@ -1266,6 +1302,8 @@ pub(crate) struct Policy {
pub inherited_sets: Vec<String>,
/// The repository's own `[supply_chain]`, or the empty one.
pub supply_chain: SupplyChain,
/// The repository's own `[stop]`, or the empty one.
pub stop: Stop,
}

impl Policy {
Expand Down Expand Up @@ -1544,6 +1582,17 @@ fn refuse_inherited_override(path: &Path, file: &PolicyFile, kind: &str) -> Resu
),
));
}
// The gate is the inheriting repository's own: what its tree has to pass
// is not a fact a bundled set can know.
if file.stop.is_some() {
return Err(Fatal::at(
path,
format!(
"`[stop]` names the commands this repository's tree has to pass, and {kind} \
has no tree. Move it to the policy of the repository it gates"
),
));
}
if let Some(id) = file.overrides.keys().next() {
return Err(Fatal::at(
path,
Expand Down Expand Up @@ -2076,6 +2125,10 @@ pub(crate) fn load_text(root: &Path, policy_path: &Path, text: &str) -> Result<P
.as_ref()
.map_or(&[], |supply| supply.waive.as_slice()),
)?;
if let Some(stop) = &file.stop {
stop.validate()
.map_err(|error| Fatal::at(policy_path, error))?;
}
validate_unique(policy_path, &rules)?;
for rule in &rules {
rule.validate()?;
Expand Down Expand Up @@ -2112,6 +2165,7 @@ pub(crate) fn load_text(root: &Path, policy_path: &Path, text: &str) -> Result<P
shims: file.shims,
inherited_sets: inherit.sets.clone(),
supply_chain: file.supply_chain.clone().unwrap_or_default(),
stop: file.stop.clone().unwrap_or_default(),
})
}

Expand Down
32 changes: 32 additions & 0 deletions src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@
//! let the call through with a complaint attached. The refusal travels in the
//! document, and it is ALSO printed to stderr, so that a person running
//! `uphold hook` by hand sees the report rather than a line of JSON.
//!
//! ## One command, two moments
//!
//! The same `uphold hook claude-code` entry is configured for `SessionStart`
//! and `Stop` as well, and `hook_event_name` says which arrived. Those two are
//! the end-of-turn gate in [`stop`]; every other event, and an event naming
//! none, is the pre-call judgment below, as it was before the gate existed.

mod stop;

use std::fmt::Write as _;
use std::io::Read;
Expand Down Expand Up @@ -199,6 +208,29 @@ pub(crate) fn run(
))
})?;

if let Some(name @ ("SessionStart" | "Stop")) =
event.pointer("/hook_event_name").and_then(Value::as_str)
{
// The gate is a repository's own declaration, so with no policy there
// is nothing to run -- said, not passed, as the guards' absence is.
if found.is_none() {
eprintln!(
"uphold hook: no policy where this {name} arrived, so no [stop] gate ran. That \
is no gate, not a passing one."
);
return Ok(Exit::Clean);
}
if evidence.is_some() {
eprintln!("uphold hook: --junit is not written for {name}; it records tool calls");
}
let (root, policy) = text::load_for(found)?;
return if name == "Stop" {
stop::stop(&event, &root, &policy)
} else {
stop::session_start(&event, &root, &policy)
};
}

// The label names the call in reports and is its `tool` subject. A missing
// name retains the harness fallback; supplied argument text is still read.
let label = event
Expand Down
Loading
Loading