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
34 changes: 34 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,40 @@ examples.
forge), so `collect = "git-refs"` replaces the argv walk rather than forking the
shim.

### Publication adapter boundaries

`invocation::Invocation` is the shared adapter-to-policy subject contract. It
carries the seam, report identity, and either pathless prose with separately
identified headlines or command-collected subject kinds. `Observed` distinguishes
a missing subject from a present empty string and from an unread required
surface. Tool identity remains available when a harness call has no argument
strings. `text::judged` consumes the pathless form; the shim consumes named
subjects under the same authoritative `text::Judged` and `text::Seam` table.
Passing a command's subjects to the pathless evaluator is an error.

Publication commands have additional facts that raw text cannot establish.
`shim::destination::CommandContext` binds the canonical arguments and collected
target to one invocation. It resolves the destination lazily and retains its
repository spelling, host, forge client and selection provenance. Scope,
ownership and destination-sensitive text guards consume that observation.
Local visibility is reused only for a destination selected from origin without
an ambient host override. A required destination that remains unresolved takes
the existing explicit unresolved-scope path; it cannot become a clean answer.

The command grammar and collection remain in `shim`; they do not evaluate
policy. `shim::checker` evaluates one selected rule against one subject, with
pattern or subprocess checks and the existing checker exit contract. The shim
orchestrator selects rules, asks scope, judges destination then subjects, and
hands the original arguments and any replayed stdin to the process. Editor
re-entry uses the same command context and checker implementations. These are
internal modules of the same binary, with no separate installation lifecycle.

Harness hooks collect strings and recognized headline fields. They do not
interpret arbitrary tool arguments as forge destinations; direct text checks
also have no destination supplied by their caller. The existing text guards'
repository context remains unchanged. Command normalization does not extend
that coverage to another transport or an unknown command grammar.

### `before` scopes a checker

Until v3 a checker was consulted by every declared shim: a check written for a
Expand Down
7 changes: 7 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2806,6 +2806,13 @@ Tool-name rules also run when arguments contain only numbers, booleans, nulls,
or empty containers. Such calls have no argument-text subject; an explicitly
supplied empty string still has one.

The hook does not infer a destination host or repository from arbitrary tool
argument names. It checks the strings and tool identity using the policy where
the call was made. Destination ownership and `public-target` checks remain
command-adapter checks; raw `scan --text` and `guard --text` input likewise
supplies no destination. A missing required argument surface is an unread
subject and exits `2`, rather than being treated as a call with no prose.

### What runs where there is no policy

Every other seam requires a policy and exits `2` without one, because a git hook
Expand Down
40 changes: 25 additions & 15 deletions src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ use serde_json::Value;

use crate::error::{Exit, Fatal, Result};
use crate::guard;
use crate::invocation::{Invocation, Observed};
use crate::shim::{Walked, walk_strings};
use crate::text::{self, Verdict};

Expand Down Expand Up @@ -182,15 +183,19 @@ pub(crate) fn run(harness: &str, found: Option<&(PathBuf, PathBuf)>) -> Result<E
// rather than an object -- that is text, and it is checked as text. The
// question here is whether the subject was found, never what shape the
// harness chose for it.
let Some(subject) = event.pointer(shape.subject) else {
return Err(Fatal::new(format!(
"the {harness} event carries nothing at {:?}, so what this call was about to \
send could not be read and \"allowed\" would mean \"unexamined\". That pointer \
is the shape this harness's events have; an event without it is a harness that \
changed rather than a call that published nothing",
shape.subject
)));
};
let observation = event.pointer(shape.subject).map_or_else(
|| {
Observed::Unread(format!(
"the {harness} event carries nothing at {:?}, so what this call was about to \
send could not be read and \"allowed\" would mean \"unexamined\". That pointer \
is the shape this harness's events have; an event without it is a harness that \
changed rather than a call that published nothing",
shape.subject
))
},
Observed::Present,
);
let subject = observation.read()?.copied();

// Everything a piece of published text is judged by, from the one place
// that knows what that is. The host-identity rules run wherever the call
Expand All @@ -211,7 +216,9 @@ pub(crate) fn run(harness: &str, found: Option<&(PathBuf, PathBuf)>) -> Result<E
let (root, policy) = text::load_for(found)?;

let mut collected = Walked::default();
walk_strings(subject, None, &mut collected);
if let Some(subject) = subject {
walk_strings(subject, None, &mut collected);
}
let has_text = !collected.prose.is_empty() || !collected.headlines.is_empty();
let text = collected.prose.join("\n");
let headlines: Vec<text::Headline> = collected
Expand All @@ -225,14 +232,17 @@ pub(crate) fn run(harness: &str, found: Option<&(PathBuf, PathBuf)>) -> Result<E

let mut report = String::new();

for verdict in text::judged(
let invocation = Invocation::text(
text::Seam::Hook,
&root,
&policy,
label,
has_text.then_some(text.as_str()),
if has_text {
Observed::Present(text.as_str())
} else {
Observed::Absent
},
&headlines,
)? {
);
for verdict in text::judged(&invocation, &root, &policy)? {
match verdict {
// The message as well as the finding. At the shim and at `--text`
// the message is what tells the author what to do instead; here the
Expand Down
142 changes: 142 additions & 0 deletions src/invocation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//! The adapter-to-policy contract for supported publication entry points.
//!
//! Pathless text and command-collected subjects remain different inputs: a
//! command has subject kinds and scope that cannot be invented for raw text.
//! Missing text is a valid observation; unread text must stop judgment.

use crate::error::{Fatal, Result};
use crate::shim::Subject;
use crate::text::{Headline, Seam};

/// What an adapter knows about one subject surface, before policy runs.
pub(crate) enum Observed<T> {
/// The surface was read and contains no subject of this kind.
Absent,
/// A subject was read, including a deliberately empty string.
Present(T),
/// The required surface could not be read, with evidence for the caller.
Unread(String),
}

/// The prose and separately identified headlines of a pathless publication.
pub(crate) struct TextInput<'a> {
/// Whole prose; absence leaves independently observed tool identity intact.
pub prose: Observed<&'a str>,
/// Headlines retain their report labels and separate guard treatment.
pub headlines: &'a [Headline],
}

impl<T> Observed<T> {
/// Reading an unavailable surface is an infrastructure error, never clean.
pub(crate) fn read(&self) -> Result<Option<&T>> {
match self {
Self::Absent => Ok(None),
Self::Present(value) => Ok(Some(value)),
Self::Unread(why) => Err(Fatal::new(why)),
}
}
}

enum Input<'a> {
Text(TextInput<'a>),
Command(&'a [Subject]),
}

/// Identity and observed subjects, before choosing applicable rule kinds.
/// Destination/scope observations belong to the command adapter's context;
/// raw text and harness events do not establish a forge destination.
pub(crate) struct Invocation<'a> {
/// Determines the authoritative set of rule kinds consulted.
pub seam: Seam,
/// Adapter identity in reports; a hook/command label is also its tool.
pub label: &'a str,
input: Input<'a>,
}

impl<'a> Invocation<'a> {
/// Normalize pathless prose without inferring a destination or path.
pub(crate) const fn text(
seam: Seam,
label: &'a str,
prose: Observed<&'a str>,
headlines: &'a [Headline],
) -> Self {
Self {
seam,
label,
input: Input::Text(TextInput { prose, headlines }),
}
}

/// Preserve the command grammar's named subject kinds for policy dispatch.
pub(crate) const fn command(label: &'a str, subjects: &'a [Subject]) -> Self {
Self {
seam: Seam::Command,
label,
input: Input::Command(subjects),
}
}

/// A tool name is independently observable, including when the tool's
/// argument object contains no strings. A source filename is not a tool.
pub(crate) fn tool(&self) -> Option<&str> {
matches!(self.seam, Seam::Hook | Seam::Command).then_some(self.label)
}

/// A command cannot lose its scope by entering the pathless evaluator.
pub(crate) fn text_input(&self) -> Result<&TextInput<'a>> {
match &self.input {
Input::Text(text) => Ok(text),
Input::Command(_) => Err(Fatal::new(
"command subjects require command scope and judgment; they cannot be judged as pathless text",
)),
}
}

/// Pathless text cannot acquire command subject kinds by guessing.
pub(crate) fn command_subjects(&self) -> Result<&[Subject]> {
match &self.input {
Input::Command(subjects) => Ok(subjects),
Input::Text(_) => Err(Fatal::new(
"pathless text has no command-collected subject kinds",
)),
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn absent_prose_keeps_tool_identity_and_unread_prose_is_an_error() {
let invocation = Invocation::text(Seam::Hook, "publish_tool", Observed::Absent, &[]);
assert_eq!(invocation.tool(), Some("publish_tool"));
assert!(
invocation
.text_input()
.unwrap()
.prose
.read()
.unwrap()
.is_none()
);
let unread = Invocation::text(
Seam::Hook,
"publish_tool",
Observed::Unread("arguments could not be read".to_owned()),
&[],
);
assert!(unread.text_input().unwrap().prose.read().is_err());
}

#[test]
fn subject_shapes_cannot_silently_cross_evaluators() {
let command = Invocation::command("faux", &[]);
assert!(command.text_input().is_err());
let text = Invocation::text(Seam::Scan, "body.md", Observed::Present(""), &[]);
assert!(text.command_subjects().is_err());
assert_eq!(text.tool(), None);
assert_eq!(text.text_input().unwrap().prose.read().unwrap(), Some(&""));
}
}
31 changes: 20 additions & 11 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ mod hook;
mod hooks;
mod init;
mod install;
mod invocation;
mod out;
mod pins;
mod policy;
Expand Down Expand Up @@ -767,17 +768,25 @@ fn guard_command(arguments: &[OsString]) -> Result<Exit> {
// Both arms become a refusal, because that is the word this command
// reports in: a finding reached through `--text` and one reached
// through a guard are the same verdict on the same text.
let refusals: Vec<guard::Refusal> =
text::judged(text::Seam::Guard, &root, &policy, &source, Some(&text), &[])?
.into_iter()
.map(|verdict| match verdict {
text::Verdict::Guard(refusal) => refusal,
text::Verdict::Rule(failure) => guard::Refusal {
report: failure.report(),
id: failure.label,
},
})
.collect();
let refusals: Vec<guard::Refusal> = text::judged(
&invocation::Invocation::text(
text::Seam::Guard,
&source,
invocation::Observed::Present(&text),
&[],
),
&root,
&policy,
)?
.into_iter()
.map(|verdict| match verdict {
text::Verdict::Guard(refusal) => refusal,
text::Verdict::Rule(failure) => guard::Refusal {
report: failure.report(),
id: failure.label,
},
})
.collect();
for refusal in &refusals {
eprintln!("guard refused: {}", guard::refused_by(&policy, refusal));
eprintln!("{}", refusal.report.trim_end());
Expand Down
Loading
Loading